feat(provider): 原生接入 Windsurf provider

This commit is contained in:
Entropy.Xu
2026-05-18 15:23:08 +08:00
parent 923515ab28
commit 0226e14251
51 changed files with 7470 additions and 157 deletions

View File

@@ -8,7 +8,7 @@ use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
use super::surfaces::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
};
pub fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
@@ -92,6 +92,7 @@ pub fn normalize_provider_private_response_value(
data
}
}
Some(WINDSURF_ENVELOPE_NAME) => normalize_windsurf_sync_response_value(data)?,
_ => return None,
};
postprocess_private_response_value(&mut unwrapped, report_context);
@@ -101,14 +102,28 @@ pub fn normalize_provider_private_response_value(
pub fn transform_provider_private_stream_line(
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, serde_json::Error> {
transform_provider_private_stream_line_with_event_state(report_context, line, &mut None)
}
fn transform_provider_private_stream_line_with_event_state(
report_context: &Value,
line: Vec<u8>,
current_event_type: &mut Option<String>,
) -> Result<Vec<u8>, serde_json::Error> {
let Ok(text) = std::str::from_utf8(&line) else {
return Ok(line);
};
let trimmed = text.trim_matches('\r').trim();
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
if trimmed.is_empty() || trimmed.starts_with(':') {
return Ok(Vec::new());
}
if let Some(event_name) = trimmed.strip_prefix("event:") {
let event_name = event_name.trim().to_string();
let is_error = event_name.eq_ignore_ascii_case("error");
*current_event_type = (!event_name.is_empty()).then_some(event_name);
return if is_error { Ok(line) } else { Ok(Vec::new()) };
}
let Some(data_line) = trimmed.strip_prefix("data:") else {
return Ok(line);
};
@@ -121,6 +136,13 @@ pub fn transform_provider_private_stream_line(
Ok(value) => value,
Err(_) => return Ok(line),
};
let event_is_error = current_event_type
.as_deref()
.is_some_and(|event| event.eq_ignore_ascii_case("error"));
*current_event_type = None;
if event_is_error {
return Ok(line);
}
let envelope_name = report_context
.get("envelope_name")
@@ -133,6 +155,9 @@ pub fn transform_provider_private_stream_line(
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
return Ok(line);
}
if envelope_name == WINDSURF_ENVELOPE_NAME && looks_like_windsurf_error(&body) {
return Ok(line);
}
let unwrapped = match envelope_name {
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
@@ -147,6 +172,7 @@ pub fn transform_provider_private_stream_line(
inject_antigravity_stream_tool_ids(&mut response);
response
}
WINDSURF_ENVELOPE_NAME => normalize_windsurf_stream_event_value(&body).unwrap_or(body),
_ => body,
};
@@ -164,6 +190,7 @@ enum ProviderPrivateStreamNormalizeMode {
pub struct ProviderPrivateStreamNormalizer<'a> {
report_context: &'a Value,
buffered: Vec<u8>,
current_event_type: Option<String>,
mode: ProviderPrivateStreamNormalizeMode,
}
@@ -203,6 +230,7 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
Some(ProviderPrivateStreamNormalizer {
report_context,
buffered: Vec::new(),
current_event_type: None,
mode,
})
}
@@ -219,8 +247,12 @@ impl ProviderPrivateStreamNormalizer<'_> {
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
output.extend(
transform_provider_private_stream_line(self.report_context, line)
.map_err(AiSurfaceFinalizeError::from)?,
transform_provider_private_stream_line_with_event_state(
self.report_context,
line,
&mut self.current_event_type,
)
.map_err(AiSurfaceFinalizeError::from)?,
);
}
Ok(output)
@@ -238,13 +270,171 @@ impl ProviderPrivateStreamNormalizer<'_> {
return Ok(Vec::new());
}
let line = std::mem::take(&mut self.buffered);
transform_provider_private_stream_line(self.report_context, line)
.map_err(AiSurfaceFinalizeError::from)
transform_provider_private_stream_line_with_event_state(
self.report_context,
line,
&mut self.current_event_type,
)
.map_err(AiSurfaceFinalizeError::from)
}
}
}
}
fn normalize_windsurf_sync_response_value(data: Value) -> Option<Value> {
if looks_like_openai_chat_response(&data) {
return Some(data);
}
if looks_like_windsurf_error(&data) {
return None;
}
if let Some(response) = data
.get("response")
.or_else(|| data.get("message"))
.or_else(|| data.get("chatMessage"))
.cloned()
{
if looks_like_openai_chat_response(&response) {
return Some(response);
}
if let Some(text) = extract_windsurf_text(&response) {
return Some(build_openai_chat_response_from_text(&data, text));
}
}
extract_windsurf_text(&data).map(|text| build_openai_chat_response_from_text(&data, text))
}
fn normalize_windsurf_stream_event_value(data: &Value) -> Option<Value> {
if looks_like_openai_chat_stream_event(data) {
return Some(data.clone());
}
if looks_like_windsurf_error(data) {
return None;
}
let response = data
.get("response")
.or_else(|| data.get("message"))
.or_else(|| data.get("chatMessage"))
.unwrap_or(data);
if looks_like_openai_chat_stream_event(response) {
return Some(response.clone());
}
extract_windsurf_text(response).map(|text| {
serde_json::json!({
"id": windsurf_response_id(data),
"object": "chat.completion.chunk",
"choices": [{
"index": 0,
"delta": {"content": text},
"finish_reason": null
}]
})
})
}
fn looks_like_openai_chat_response(value: &Value) -> bool {
value
.get("choices")
.and_then(Value::as_array)
.is_some_and(|choices| !choices.is_empty())
}
fn looks_like_openai_chat_stream_event(value: &Value) -> bool {
value
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(Value::as_object)
.is_some_and(|choice| choice.contains_key("delta"))
}
fn looks_like_windsurf_error(value: &Value) -> bool {
let Some(object) = value.as_object() else {
return false;
};
if object.contains_key("error") {
return true;
}
if object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
{
return true;
}
if object.contains_key("code") || object.contains_key("status") {
return object
.get("message")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
}
object
.get("message")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& !object.contains_key("response")
&& !object.contains_key("chatMessage")
&& !object.contains_key("choices")
&& !object.contains_key("text")
&& !object.contains_key("content")
&& !object.contains_key("assistantMessage")
&& !object.contains_key("assistant_message")
}
fn extract_windsurf_text(value: &Value) -> Option<String> {
if let Some(text) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(text.to_string());
}
let object = value.as_object()?;
for key in [
"text",
"content",
"message",
"answer",
"completion",
"assistantMessage",
"assistant_message",
] {
if let Some(text) = object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(text.to_string());
}
}
None
}
fn windsurf_response_id(value: &Value) -> String {
value
.get("id")
.or_else(|| value.get("responseId"))
.or_else(|| value.get("messageId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("windsurf-cascade")
.to_string()
}
fn build_openai_chat_response_from_text(source: &Value, text: String) -> Value {
serde_json::json!({
"id": windsurf_response_id(source),
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": text},
"finish_reason": "stop"
}]
})
}
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(body) else {
return false;
@@ -497,6 +687,47 @@ mod tests {
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
}
#[test]
fn normalizes_windsurf_sync_text_response_to_openai_chat() {
let report_context = json!({
"has_envelope": true,
"envelope_name": "windsurf:GetChatMessage",
"provider_api_format": "openai:chat",
});
let normalized = normalize_provider_private_response_value(
json!({
"responseId": "ws-1",
"response": {"text": "hello from cascade"}
}),
&report_context,
)
.expect("windsurf response should normalize");
assert_eq!(normalized["id"], json!("ws-1"));
assert_eq!(
normalized["choices"][0]["message"]["content"],
json!("hello from cascade")
);
}
#[test]
fn unwraps_windsurf_stream_text_event() {
let report_context = json!({
"has_envelope": true,
"envelope_name": "windsurf:GetChatMessage",
"provider_api_format": "openai:chat",
});
let output = transform_provider_private_stream_line(
&report_context,
br#"data: {"responseId":"ws-2","response":{"text":"chunk"}}"#.to_vec(),
)
.expect("windsurf stream line should transform");
let text = String::from_utf8(output).expect("utf8");
assert!(text.contains(r#""object":"chat.completion.chunk""#));
assert!(text.contains(r#""content":"chunk""#));
}
#[test]
fn private_stream_normalizer_unwraps_antigravity_stream() {
let report_context = json!({
@@ -526,4 +757,39 @@ data: {"message":"bad"}
"#;
assert!(stream_body_contains_error_event(body));
}
#[test]
fn windsurf_sync_error_message_is_not_normalized_as_success() {
let report_context = json!({
"has_envelope": true,
"envelope_name": "windsurf:GetChatMessage",
"provider_api_format": "openai:chat",
});
let normalized = normalize_provider_private_response_value(
json!({"message": "rate limited"}),
&report_context,
);
assert!(normalized.is_none());
}
#[test]
fn windsurf_stream_error_event_is_preserved() {
let report_context = json!({
"has_envelope": true,
"envelope_name": "windsurf:GetChatMessage",
"provider_api_format": "openai:chat",
});
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
.expect("normalizer should exist");
let output = normalizer
.push_chunk(b"event: error\ndata: {\"message\":\"rate limited\"}\n\n")
.expect("normalizer should preserve error event");
let output_text = String::from_utf8(output).expect("utf8");
assert!(output_text.contains("event: error"));
assert!(output_text.contains("\"message\":\"rate limited\""));
assert!(!output_text.contains("chat.completion.chunk"));
}
}

View File

@@ -1,8 +1,10 @@
pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
pub const WINDSURF_PROVIDER_TYPE: &str = "windsurf";
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
pub const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
pub const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAdaptationSurface {
@@ -10,6 +12,7 @@ pub enum ProviderAdaptationSurface {
AntigravityGeminiCli,
GeminiCliV1Internal,
KiroClaudeCli,
WindsurfCascade,
}
#[derive(Debug, Clone, Copy)]
@@ -70,6 +73,17 @@ const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
requires_eventstream_accept: true,
unwraps_response_envelope: false,
},
ProviderAdaptationDescriptor {
surface: ProviderAdaptationSurface::WindsurfCascade,
provider_type: Some(WINDSURF_PROVIDER_TYPE),
envelope_name: WINDSURF_ENVELOPE_NAME,
anchor_api_format: "openai:chat",
supports_request_bridge: true,
supports_sync_finalize_bridge: true,
supports_stream_bridge: true,
requires_eventstream_accept: false,
unwraps_response_envelope: true,
},
];
pub fn provider_adaptation_descriptor_for_envelope(
@@ -141,7 +155,7 @@ mod tests {
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
};
#[test]
@@ -164,6 +178,10 @@ mod tests {
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:messages"),
Some("claude:messages")
);
assert_eq!(
provider_adaptation_anchor_api_format(WINDSURF_ENVELOPE_NAME, "openai:chat"),
Some("openai:chat")
);
}
#[test]
@@ -184,5 +202,9 @@ mod tests {
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
"gemini:generate_content"
));
assert!(provider_adaptation_should_unwrap_stream_envelope(
WINDSURF_ENVELOPE_NAME,
"openai:chat"
));
}
}