mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 12:09:50 +08:00
feat(provider): 原生接入 Windsurf provider
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::rules::{
|
||||
apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers,
|
||||
body_rules_are_locally_supported, header_rules_are_locally_supported,
|
||||
};
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::build_passthrough_path_url;
|
||||
use crate::{
|
||||
resolve_transport_profile, should_skip_upstream_passthrough_header,
|
||||
supports_local_oauth_request_auth_resolution, transport_profile_is_configured,
|
||||
transport_proxy_is_locally_supported,
|
||||
};
|
||||
|
||||
pub const PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
pub const GET_CHAT_MESSAGE_PATH: &str = "/exa.api_server_pb.ApiServerService/GetChatMessage";
|
||||
const DEFAULT_IDE_VERSION: &str = "1.9600.41";
|
||||
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
||||
|
||||
pub fn is_windsurf_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROVIDER_TYPE)
|
||||
}
|
||||
|
||||
pub fn local_windsurf_request_transport_unsupported_reason_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
if !transport.provider.is_active {
|
||||
return Some("provider_inactive");
|
||||
}
|
||||
if !transport.endpoint.is_active {
|
||||
return Some("endpoint_inactive");
|
||||
}
|
||||
if !transport.key.is_active {
|
||||
return Some("key_inactive");
|
||||
}
|
||||
if !is_windsurf_provider_transport(transport) {
|
||||
return Some("transport_provider_type_unsupported");
|
||||
}
|
||||
if !transport
|
||||
.endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:chat")
|
||||
{
|
||||
return Some("transport_api_format_mismatch");
|
||||
}
|
||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
||||
return Some("transport_header_rules_unsupported");
|
||||
}
|
||||
if !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) {
|
||||
return Some("transport_body_rules_unsupported");
|
||||
}
|
||||
if transport.key.decrypted_auth_config.is_some()
|
||||
&& !supports_local_oauth_request_auth_resolution(transport)
|
||||
&& !supports_local_windsurf_request_auth_resolution(transport)
|
||||
{
|
||||
return Some("transport_oauth_resolution_unsupported");
|
||||
}
|
||||
if !transport_proxy_is_locally_supported(transport) {
|
||||
return Some("transport_proxy_unsupported");
|
||||
}
|
||||
if transport_profile_is_configured(transport) && resolve_transport_profile(transport).is_none()
|
||||
{
|
||||
return Some("transport_profile_unsupported");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_local_windsurf_request_auth_resolution(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
resolve_windsurf_cascade_auth(transport).is_some()
|
||||
}
|
||||
|
||||
pub fn resolve_windsurf_cascade_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<(String, String)> {
|
||||
if !is_windsurf_provider_transport(transport) {
|
||||
return None;
|
||||
}
|
||||
let auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
|
||||
if !matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer") {
|
||||
return None;
|
||||
}
|
||||
let secret = transport.key.decrypted_api_key.trim();
|
||||
if secret.is_empty() || secret == PLACEHOLDER_API_KEY {
|
||||
return None;
|
||||
}
|
||||
Some(("authorization".to_string(), format!("Bearer {secret}")))
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_upstream_url(
|
||||
upstream_base_url: &str,
|
||||
query: Option<&str>,
|
||||
) -> Option<String> {
|
||||
build_passthrough_path_url(upstream_base_url, GET_CHAT_MESSAGE_PATH, query, &[])
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
auth_value: &str,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: Option<&http::HeaderMap>,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let messages = body_json.get("messages")?.as_array()?.clone();
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let conversation_id =
|
||||
extract_conversation_id(body_json).unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let message_text = last_user_message_text(&messages).unwrap_or_else(|| "Continue.".to_string());
|
||||
let mut provider_request_body = json!({
|
||||
"metadata": windsurf_metadata_from_auth(auth_value),
|
||||
"model": mapped_model,
|
||||
"modelName": mapped_model,
|
||||
"stream": upstream_is_stream,
|
||||
"conversationId": conversation_id,
|
||||
"message": message_text,
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
if let Some(max_tokens) = body_json
|
||||
.get("max_tokens")
|
||||
.or_else(|| body_json.get("maxTokens"))
|
||||
{
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("maxTokens".to_string(), max_tokens.clone());
|
||||
}
|
||||
if let Some(temperature) = body_json.get("temperature") {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("temperature".to_string(), temperature.clone());
|
||||
}
|
||||
if let Some(top_p) = body_json.get("top_p").or_else(|| body_json.get("topP")) {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("topP".to_string(), top_p.clone());
|
||||
}
|
||||
|
||||
if !apply_local_body_rules_with_request_headers(
|
||||
&mut provider_request_body,
|
||||
body_rules,
|
||||
Some(body_json),
|
||||
request_headers,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_headers(
|
||||
headers: &http::HeaderMap,
|
||||
provider_request_body: &Value,
|
||||
original_request_body: &Value,
|
||||
header_rules: Option<&Value>,
|
||||
auth_header: &str,
|
||||
auth_value: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, value) in headers {
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let key = name.as_str().to_ascii_lowercase();
|
||||
if should_skip_upstream_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
out.insert(key, value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let auth_header = auth_header.trim().to_ascii_lowercase();
|
||||
if !apply_local_header_rules_with_request_headers(
|
||||
&mut out,
|
||||
header_rules,
|
||||
&[
|
||||
auth_header.as_str(),
|
||||
"content-type",
|
||||
"connect-protocol-version",
|
||||
],
|
||||
provider_request_body,
|
||||
Some(original_request_body),
|
||||
Some(headers),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
out.insert("content-type".to_string(), "application/json".to_string());
|
||||
out.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
out.insert(
|
||||
"user-agent".to_string(),
|
||||
format!("windsurf/{DEFAULT_IDE_VERSION}"),
|
||||
);
|
||||
out.insert(
|
||||
"accept".to_string(),
|
||||
if upstream_is_stream {
|
||||
"text/event-stream".to_string()
|
||||
} else {
|
||||
"application/json".to_string()
|
||||
},
|
||||
);
|
||||
if !auth_header.is_empty() {
|
||||
out.insert(auth_header, auth_value.trim().to_string());
|
||||
}
|
||||
out.remove("content-length");
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn windsurf_metadata_from_auth(auth_value: &str) -> Value {
|
||||
json!({
|
||||
"apiKey": auth_secret_from_header_value(auth_value),
|
||||
"ideName": "windsurf",
|
||||
"ideVersion": DEFAULT_IDE_VERSION,
|
||||
"extensionName": "windsurf",
|
||||
"extensionVersion": DEFAULT_IDE_VERSION,
|
||||
"locale": "en",
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_secret_from_header_value(auth_value: &str) -> String {
|
||||
let value = auth_value.trim();
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or(value)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_conversation_id(body_json: &Value) -> Option<String> {
|
||||
let object = body_json.as_object()?;
|
||||
string_value(object.get("conversation_id"))
|
||||
.or_else(|| string_value(object.get("conversationId")))
|
||||
.or_else(|| string_value(object.get("session_id")))
|
||||
.or_else(|| string_value(object.get("sessionId")))
|
||||
.or_else(|| {
|
||||
object
|
||||
.get("metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| {
|
||||
string_value(metadata.get("conversation_id"))
|
||||
.or_else(|| string_value(metadata.get("conversationId")))
|
||||
.or_else(|| string_value(metadata.get("session_id")))
|
||||
.or_else(|| string_value(metadata.get("sessionId")))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn string_value(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn last_user_message_text(messages: &[Value]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(Value::as_object)
|
||||
.find(|message| {
|
||||
message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| role == "user")
|
||||
})
|
||||
.and_then(|message| openai_content_to_text(message.get("content")))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn openai_content_to_text(value: Option<&Value>) -> Option<String> {
|
||||
match value? {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Array(items) => {
|
||||
let parts = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(!parts.is_empty()).then(|| parts.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{
|
||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||
build_windsurf_cascade_upstream_url,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||
resolve_windsurf_cascade_auth, GET_CHAT_MESSAGE_PATH,
|
||||
};
|
||||
|
||||
fn sample_windsurf_transport(auth_type: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-windsurf".to_string(),
|
||||
name: "Windsurf".to_string(),
|
||||
provider_type: "windsurf".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-windsurf-chat".to_string(),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://server.codeium.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-windsurf".to_string(),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
name: "[email protected]".to_string(),
|
||||
auth_type: auth_type.to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "devin-session-token$abc".to_string(),
|
||||
decrypted_auth_config: Some(r#"{"provider_type":"windsurf"}"#.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_windsurf_cascade_url() {
|
||||
assert_eq!(
|
||||
build_windsurf_cascade_upstream_url("https://server.codeium.com", Some("debug=1"))
|
||||
.as_deref(),
|
||||
Some(
|
||||
"https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage?debug=1"
|
||||
)
|
||||
);
|
||||
assert!(GET_CHAT_MESSAGE_PATH.ends_with("/GetChatMessage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_request_body_with_metadata_and_messages() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5",
|
||||
"conversation_id": "conv-1",
|
||||
"messages": [
|
||||
{"role": "system", "content": "brief"},
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
||||
],
|
||||
"max_tokens": 128
|
||||
}),
|
||||
"windsurf-model",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["metadata"]["apiKey"], json!("devin-session-token$abc"));
|
||||
assert_eq!(body["modelName"], json!("windsurf-model"));
|
||||
assert_eq!(body["stream"], json!(true));
|
||||
assert_eq!(body["conversationId"], json!("conv-1"));
|
||||
assert_eq!(body["message"], json!("hello"));
|
||||
assert_eq!(body["maxTokens"], json!(128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_headers_with_connect_protocol_and_auth() {
|
||||
let headers = build_windsurf_cascade_headers(
|
||||
&HeaderMap::new(),
|
||||
&json!({"metadata": {"apiKey": "secret"}}),
|
||||
&json!({"messages": []}),
|
||||
None,
|
||||
"authorization",
|
||||
"Bearer secret",
|
||||
false,
|
||||
)
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("connect-protocol-version").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer secret")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_windsurf_transport_resolves_direct_bearer_auth() {
|
||||
let transport = sample_windsurf_transport("oauth");
|
||||
|
||||
assert_eq!(
|
||||
local_windsurf_request_transport_unsupported_reason_with_network(&transport),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_windsurf_cascade_auth(&transport),
|
||||
Some((
|
||||
"authorization".to_string(),
|
||||
"Bearer devin-session-token$abc".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user