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

@@ -922,6 +922,317 @@ pub fn parse_kiro_usage_response(
Some(serde_json::Value::Object(result))
}
pub fn parse_windsurf_user_status_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let user_status = value
.get("userStatus")
.or_else(|| value.get("user_status"))?;
let plan_status = user_status
.get("planStatus")
.or_else(|| user_status.get("plan_status"))?;
let plan_info = plan_status
.get("planInfo")
.or_else(|| plan_status.get("plan_info"));
let mut result = serde_json::Map::new();
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
if let Some(plan_name) = plan_info
.and_then(|value| {
coerce_json_string(value.get("planName").or_else(|| value.get("plan_name")))
})
.or_else(|| {
coerce_json_string(
plan_status
.get("planName")
.or_else(|| plan_status.get("plan_name")),
)
})
{
result.insert("plan_name".to_string(), json!(plan_name));
}
if let Some(email) = coerce_json_string(user_status.get("email")) {
result.insert("email".to_string(), json!(email));
}
if let Some(value) = plan_status
.get("dailyQuotaRemainingPercent")
.or_else(|| plan_status.get("daily_quota_remaining_percent"))
.and_then(coerce_json_f64)
{
result.insert("daily_remaining_percent".to_string(), json!(value));
}
if let Some(value) = plan_status
.get("weeklyQuotaRemainingPercent")
.or_else(|| plan_status.get("weekly_quota_remaining_percent"))
.and_then(coerce_json_f64)
{
result.insert("weekly_remaining_percent".to_string(), json!(value));
}
if let Some(value) = plan_status
.get("dailyQuotaResetAtUnix")
.or_else(|| plan_status.get("daily_quota_reset_at_unix"))
.and_then(coerce_json_u64)
{
result.insert("daily_reset_at".to_string(), json!(value));
}
if let Some(value) = plan_status
.get("weeklyQuotaResetAtUnix")
.or_else(|| plan_status.get("weekly_quota_reset_at_unix"))
.and_then(coerce_json_u64)
{
result.insert("weekly_reset_at".to_string(), json!(value));
}
if let Some(value) = plan_status
.get("overageBalanceMicros")
.or_else(|| plan_status.get("overage_balance_micros"))
.and_then(coerce_json_f64)
{
result.insert("overage_balance".to_string(), json!(value / 1_000_000.0));
}
let legacy_credit =
|value: Option<&serde_json::Value>| value.and_then(coerce_json_f64).map(|n| n / 100.0);
if let Some(value) = legacy_credit(
plan_status
.get("availablePromptCredits")
.or_else(|| plan_status.get("available_prompt_credits")),
) {
result.insert("prompt_remaining".to_string(), json!(value));
}
if let Some(value) = legacy_credit(
plan_status
.get("usedPromptCredits")
.or_else(|| plan_status.get("used_prompt_credits")),
) {
result.insert("prompt_used".to_string(), json!(value));
}
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
plan_info
.get("monthlyPromptCredits")
.or_else(|| plan_info.get("monthly_prompt_credits"))
})) {
result.insert("prompt_limit".to_string(), json!(value));
}
if let Some(value) = legacy_credit(
plan_status
.get("availableFlexCredits")
.or_else(|| plan_status.get("available_flex_credits")),
) {
result.insert("flex_remaining".to_string(), json!(value));
}
if let Some(value) = legacy_credit(
plan_status
.get("usedFlexCredits")
.or_else(|| plan_status.get("used_flex_credits")),
) {
result.insert("flex_used".to_string(), json!(value));
}
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
plan_info
.get("monthlyFlexCreditPurchaseAmount")
.or_else(|| plan_info.get("monthly_flex_credit_purchase_amount"))
})) {
result.insert("flex_limit".to_string(), json!(value));
}
let mut status_sources = vec![value, user_status, plan_status];
if let Some(plan_info) = plan_info {
status_sources.push(plan_info);
}
for (target, aliases) in [
(
"banned",
&[
"banned",
"isBanned",
"is_banned",
"accountBanned",
"account_banned",
][..],
),
(
"quarantined",
&[
"quarantined",
"isQuarantined",
"is_quarantined",
"accountQuarantined",
"account_quarantined",
][..],
),
(
"is_forbidden",
&[
"isForbidden",
"is_forbidden",
"forbidden",
"accountForbidden",
"account_forbidden",
][..],
),
] {
if let Some(found) = status_sources.iter().find_map(|source| {
aliases
.iter()
.find_map(|alias| source.get(*alias).and_then(coerce_json_bool))
}) {
result.insert(target.to_string(), json!(found));
}
}
for (target, aliases) in [
(
"ban_reason",
&[
"banReason",
"ban_reason",
"blockedReason",
"blocked_reason",
"reason",
"message",
][..],
),
(
"quarantine_reason",
&["quarantineReason", "quarantine_reason", "reason", "message"][..],
),
(
"forbidden_reason",
&["forbiddenReason", "forbidden_reason", "reason", "message"][..],
),
] {
if let Some(found) = status_sources.iter().find_map(|source| {
aliases
.iter()
.find_map(|alias| coerce_json_string(source.get(*alias)))
}) {
result.insert(target.to_string(), json!(found));
}
}
Some(serde_json::Value::Object(result))
}
pub fn parse_windsurf_model_configs_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let configs = value
.get("clientModelConfigs")
.or_else(|| value.get("client_model_configs"))
.and_then(serde_json::Value::as_array)?;
let mut models = Vec::new();
for config in configs {
let Some(model_uid) = coerce_json_string(
config
.get("modelUid")
.or_else(|| config.get("model_uid"))
.or_else(|| config.get("id"))
.or_else(|| config.get("name")),
) else {
continue;
};
let mut model = serde_json::Map::new();
model.insert("model_uid".to_string(), json!(model_uid));
if let Some(label) = coerce_json_string(
config
.get("label")
.or_else(|| config.get("displayName"))
.or_else(|| config.get("display_name")),
) {
model.insert("label".to_string(), json!(label));
}
if let Some(provider) = coerce_json_string(config.get("provider")) {
model.insert("provider".to_string(), json!(provider));
}
if let Some(value) = config
.get("supportsImages")
.or_else(|| config.get("supports_images"))
.and_then(coerce_json_bool)
{
model.insert("supports_images".to_string(), json!(value));
}
if let Some(value) = config
.get("creditMultiplier")
.or_else(|| config.get("credit_multiplier"))
.and_then(coerce_json_f64)
{
model.insert("credit_multiplier".to_string(), json!(value));
}
models.push(serde_json::Value::Object(model));
}
let mut result = serde_json::Map::new();
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
result.insert(
"allowed_models_count".to_string(),
json!(models.len() as u64),
);
result.insert("models".to_string(), serde_json::Value::Array(models));
if let Some(default_model_uid) = value
.get("defaultOverrideModelConfig")
.or_else(|| value.get("default_override_model_config"))
.and_then(|default_config| {
coerce_json_string(
default_config
.get("modelUid")
.or_else(|| default_config.get("model_uid")),
)
})
{
result.insert("default_model_uid".to_string(), json!(default_model_uid));
}
Some(serde_json::Value::Object(result))
}
pub fn parse_windsurf_rate_limit_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
let root = value.as_object()?;
if root.is_empty() {
return None;
}
let has_capacity = value
.get("hasCapacity")
.or_else(|| value.get("has_capacity"))
.and_then(coerce_json_bool)
.unwrap_or(true);
let messages_remaining = value
.get("messagesRemaining")
.or_else(|| value.get("messages_remaining"))
.and_then(coerce_json_f64);
let max_messages = value
.get("maxMessages")
.or_else(|| value.get("max_messages"))
.and_then(coerce_json_f64);
let retry_after_ms = value
.get("retryAfterMs")
.or_else(|| value.get("retry_after_ms"))
.and_then(coerce_json_u64);
let limited = !has_capacity || messages_remaining.is_some_and(|value| value <= 0.0);
let mut rate_limit = serde_json::Map::new();
rate_limit.insert("limited".to_string(), json!(limited));
rate_limit.insert("has_capacity".to_string(), json!(has_capacity));
if let Some(value) = messages_remaining {
rate_limit.insert("messages_remaining".to_string(), json!(value));
}
if let Some(value) = max_messages {
rate_limit.insert("max_messages".to_string(), json!(value));
}
if let Some(value) = retry_after_ms {
rate_limit.insert("retry_after_ms".to_string(), json!(value));
}
Some(json!({
"updated_at": updated_at_unix_secs,
"rate_limit": rate_limit,
}))
}
fn chatgpt_web_quota_feature_name(value: &serde_json::Value) -> Option<String> {
coerce_json_string(
value
@@ -1141,8 +1452,10 @@ mod tests {
use super::{
codex_build_invalid_state, codex_runtime_invalid_reason,
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
parse_codex_wham_usage_response, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
OAUTH_REFRESH_FAILED_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
parse_codex_wham_usage_response, parse_windsurf_model_configs_response,
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
OAUTH_REQUEST_FAILED_PREFIX,
};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::json;
@@ -1504,6 +1817,118 @@ mod tests {
assert!(parsed.get("secondary_used_percent").is_none());
}
#[test]
fn parses_windsurf_user_status_response() {
let parsed = parse_windsurf_user_status_response(
&json!({
"userStatus": {
"email": "windsurf@example.com",
"isQuarantined": true,
"quarantineReason": "quota review",
"planStatus": {
"dailyQuotaRemainingPercent": 45.5,
"weeklyQuotaRemainingPercent": 80,
"dailyQuotaResetAtUnix": "1775553285",
"weeklyQuotaResetAtUnix": 1776158085u64,
"availablePromptCredits": 900,
"usedPromptCredits": 100,
"availableFlexCredits": 250,
"usedFlexCredits": 50,
"overageBalanceMicros": 1250000,
"planInfo": {
"planName": "Pro",
"monthlyPromptCredits": 1000,
"monthlyFlexCreditPurchaseAmount": 300
}
}
}
}),
1_770_000_000,
)
.expect("windsurf user status should parse");
assert_eq!(parsed.get("plan_name"), Some(&json!("Pro")));
assert_eq!(parsed.get("daily_remaining_percent"), Some(&json!(45.5)));
assert_eq!(parsed.get("weekly_remaining_percent"), Some(&json!(80.0)));
assert_eq!(parsed.get("daily_reset_at"), Some(&json!(1_775_553_285u64)));
assert_eq!(
parsed.get("weekly_reset_at"),
Some(&json!(1_776_158_085u64))
);
assert_eq!(parsed.get("prompt_remaining"), Some(&json!(9.0)));
assert_eq!(parsed.get("prompt_used"), Some(&json!(1.0)));
assert_eq!(parsed.get("prompt_limit"), Some(&json!(10.0)));
assert_eq!(parsed.get("flex_remaining"), Some(&json!(2.5)));
assert_eq!(parsed.get("flex_used"), Some(&json!(0.5)));
assert_eq!(parsed.get("flex_limit"), Some(&json!(3.0)));
assert_eq!(parsed.get("overage_balance"), Some(&json!(1.25)));
assert_eq!(parsed.get("email"), Some(&json!("windsurf@example.com")));
assert_eq!(parsed.get("quarantined"), Some(&json!(true)));
assert_eq!(
parsed.get("quarantine_reason"),
Some(&json!("quota review"))
);
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_000u64)));
}
#[test]
fn parses_windsurf_model_configs_response() {
let parsed = parse_windsurf_model_configs_response(
&json!({
"clientModelConfigs": [
{
"modelUid": "claude-sonnet-4-5",
"label": "Claude Sonnet 4.5",
"provider": "anthropic",
"supportsImages": true,
"creditMultiplier": 2
},
{
"modelUid": "gpt-5-mini",
"label": "GPT-5 mini"
}
],
"defaultOverrideModelConfig": {
"modelUid": "claude-sonnet-4-5"
}
}),
1_770_000_100,
)
.expect("windsurf model configs should parse");
assert_eq!(parsed.get("allowed_models_count"), Some(&json!(2u64)));
assert_eq!(
parsed.get("default_model_uid"),
Some(&json!("claude-sonnet-4-5"))
);
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_100u64)));
}
#[test]
fn parses_windsurf_rate_limit_response() {
let parsed = parse_windsurf_rate_limit_response(
&json!({
"hasCapacity": false,
"messagesRemaining": 0,
"maxMessages": 25,
"retryAfterMs": 45000
}),
1_770_000_200,
)
.expect("windsurf rate limit should parse");
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_200u64)));
assert_eq!(parsed.pointer("/rate_limit/limited"), Some(&json!(true)));
assert_eq!(
parsed.pointer("/rate_limit/messages_remaining"),
Some(&json!(0.0))
);
assert_eq!(
parsed.pointer("/rate_limit/retry_after_ms"),
Some(&json!(45000u64))
);
}
#[test]
fn parses_chatgpt_web_image_quota_from_conversation_init() {
let parsed = parse_chatgpt_web_conversation_init_response(

View File

@@ -37,9 +37,27 @@ pub fn provider_oauth_pkce_s256(verifier: &str) -> String {
pub fn parse_provider_oauth_callback_params(callback_url: &str) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
let Ok(url) = Url::parse(callback_url.trim()) else {
let raw_callback_url = callback_url.trim();
let parsed_url = Url::parse(raw_callback_url).or_else(|_| {
Url::parse(&format!(
"https://aether.local/{}",
raw_callback_url.trim_start_matches('/')
))
});
let Ok(url) = parsed_url else {
return merged;
};
if url.query().is_none()
&& url.fragment().is_none()
&& raw_callback_url.contains('=')
&& !raw_callback_url.contains("://")
{
for (key, value) in
form_urlencoded::parse(raw_callback_url.trim_start_matches('?').as_bytes())
{
merged.insert(key.into_owned(), value.into_owned());
}
}
for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) {
merged.insert(key.into_owned(), value.into_owned());
}
@@ -377,6 +395,28 @@ mod tests {
assert_eq!(params.get("state").map(String::as_str), Some("nonce-value"));
}
#[test]
fn parse_provider_oauth_callback_params_reads_relative_show_auth_token_url() {
let params = parse_provider_oauth_callback_params(
"show-auth-token?token=firebase-id-token&state=session-1&provider=google",
);
assert_eq!(
params.get("token").map(String::as_str),
Some("firebase-id-token")
);
assert_eq!(params.get("state").map(String::as_str), Some("session-1"));
assert_eq!(params.get("provider").map(String::as_str), Some("google"));
}
#[test]
fn parse_provider_oauth_callback_params_reads_raw_query_string() {
let params = parse_provider_oauth_callback_params("token=raw-token&state=session-raw");
assert_eq!(params.get("token").map(String::as_str), Some("raw-token"));
assert_eq!(params.get("state").map(String::as_str), Some("session-raw"));
}
#[test]
fn chatgpt_web_enrichment_extracts_identity_from_openai_claims() {
let access_token = sample_unsigned_jwt(json!({

View File

@@ -40,6 +40,7 @@ const AUTO_REMOVABLE_ACCOUNT_STATE_CODES: &[&str] = &[
"account_banned",
"account_suspended",
"account_disabled",
"account_quarantined",
"workspace_deactivated",
"account_forbidden",
];
@@ -275,7 +276,7 @@ fn resolve_from_metadata(
upstream_metadata: Option<&Value>,
) -> Option<PoolAccountState> {
for source in metadata_sources(provider_type, upstream_metadata) {
if json_bool(source.get("is_banned")) {
if json_bool(source.get("is_banned")) || json_bool(source.get("banned")) {
let reason = extract_reason(
source,
&["ban_reason", "forbidden_reason", "reason", "message"],
@@ -290,6 +291,18 @@ fn resolve_from_metadata(
recoverable: false,
});
}
if json_bool(source.get("is_quarantined")) || json_bool(source.get("quarantined")) {
let reason = extract_reason(source, &["quarantine_reason", "reason", "message"])
.unwrap_or_else(|| "账号处于隔离状态".to_string());
return Some(PoolAccountState {
blocked: true,
code: Some("account_quarantined".to_string()),
label: Some("账号隔离".to_string()),
reason: Some(reason),
source: Some("metadata".to_string()),
recoverable: false,
});
}
if json_bool(source.get("is_forbidden")) || json_bool(source.get("account_disabled")) {
let reason = extract_reason(
source,
@@ -574,6 +587,35 @@ mod tests {
assert!(!should_auto_remove_account_state(&state));
}
#[test]
fn resolves_windsurf_banned_and_quarantined_metadata_aliases() {
let banned = resolve_pool_account_state(
Some("windsurf"),
Some(&json!({
"windsurf": {
"banned": true,
"reason": "forbidden"
}
})),
None,
);
assert!(banned.blocked);
assert_eq!(banned.code.as_deref(), Some("account_banned"));
let quarantined = resolve_pool_account_state(
Some("windsurf"),
Some(&json!({
"windsurf": {
"quarantined": true
}
})),
None,
);
assert!(quarantined.blocked);
assert_eq!(quarantined.code.as_deref(), Some("account_quarantined"));
assert!(should_auto_remove_account_state(&quarantined));
}
#[test]
fn account_snapshot_ignores_refresh_failed_as_account_block() {
let snapshot = resolve_account_status_snapshot(

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"
));
}
}

View File

@@ -323,6 +323,10 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
)
}
"windsurf" => {
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
&& api_format == "openai:chat"
}
"vertex_ai" => {
(auth_type == "api_key"
&& matches!(
@@ -557,6 +561,34 @@ mod tests {
);
}
#[tokio::test]
async fn allows_windsurf_managed_keys_for_openai_chat_only() {
let mut oauth = sample_row("windsurf-oauth", "openai:chat", "gpt-5", 10);
oauth.provider_type = "windsurf".to_string();
oauth.key_auth_type = "oauth".to_string();
let mut api_key = sample_row("windsurf-api-key", "openai:chat", "gpt-5", 20);
api_key.provider_type = "windsurf".to_string();
api_key.key_auth_type = "api_key".to_string();
let mut responses = sample_row("windsurf-responses", "openai:responses", "gpt-5", 30);
responses.provider_type = "windsurf".to_string();
responses.key_auth_type = "oauth".to_string();
let repository =
InMemoryMinimalCandidateSelectionReadRepository::seed(vec![oauth, api_key, responses]);
let rows = repository
.list_for_exact_api_format_and_requested_model("openai:chat", "gpt-5")
.await
.expect("list should succeed");
assert_eq!(
rows.iter()
.map(|row| row.provider_id.as_str())
.collect::<Vec<_>>(),
vec!["windsurf-oauth", "windsurf-api-key"]
);
}
#[tokio::test]
async fn filters_by_exact_api_format_only() {
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![

View File

@@ -452,6 +452,10 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
)
}
"windsurf" => {
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
&& api_format == "openai:chat"
}
"vertex_ai" => {
(auth_type == "api_key"
&& matches!(

View File

@@ -113,6 +113,11 @@ WHERE p.is_active = TRUE
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
AND LOWER($3) = 'gemini:generate_content'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'windsurf'
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
AND LOWER($3) = 'openai:chat'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
AND (
@@ -135,7 +140,8 @@ WHERE p.is_active = TRUE
'grok',
'vertex_ai',
'antigravity',
'kiro'
'kiro',
'windsurf'
)
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
)
@@ -302,6 +308,11 @@ WHERE p.is_active = TRUE
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
AND LOWER($4) = 'gemini:generate_content'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'windsurf'
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
AND LOWER($4) = 'openai:chat'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
AND (
@@ -324,7 +335,8 @@ WHERE p.is_active = TRUE
'grok',
'vertex_ai',
'antigravity',
'kiro'
'kiro',
'windsurf'
)
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
)
@@ -490,6 +502,11 @@ WHERE p.is_active = TRUE
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
AND LOWER($6) = 'gemini:generate_content'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'windsurf'
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
AND LOWER($6) = 'openai:chat'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
AND (
@@ -512,7 +529,8 @@ WHERE p.is_active = TRUE
'grok',
'vertex_ai',
'antigravity',
'kiro'
'kiro',
'windsurf'
)
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
)
@@ -1322,6 +1340,26 @@ mod tests {
}
}
#[test]
fn candidate_selection_sql_allows_windsurf_openai_chat_managed_keys() {
let requested_model_sql = requested_model_selection_sql();
for sql in [
LIST_FOR_EXACT_API_FORMAT_SQL,
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
LIST_POOL_KEYS_FOR_GROUP_SQL,
requested_model_sql.as_str(),
] {
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'windsurf'"));
assert!(
sql.contains("LOWER($3) = 'openai:chat'")
|| sql.contains("LOWER($4) = 'openai:chat'")
|| sql.contains("LOWER($6) = 'openai:chat'")
);
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')"));
assert!(sql.contains("'windsurf'"));
}
}
#[test]
fn candidate_selection_sql_allows_vertex_embedding_auth() {
let requested_model_sql = requested_model_selection_sql();

View File

@@ -831,6 +831,10 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
)
}
"windsurf" => {
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
&& api_format == "openai:chat"
}
"vertex_ai" => {
(auth_type == "api_key"
&& matches!(

View File

@@ -2,6 +2,7 @@ mod antigravity;
mod codex;
mod generic;
mod kiro;
mod windsurf;
pub use antigravity::AntigravityProviderOAuthAdapter;
pub use codex::CodexProviderOAuthAdapter;
@@ -13,3 +14,7 @@ pub use kiro::{
DEFAULT_KIRO_VERSION, DEFAULT_NODE_VERSION, DEFAULT_REGION, DEFAULT_SYSTEM_VERSION,
KIRO_PROVIDER_TYPE,
};
pub use windsurf::{
WindsurfProviderOAuthAdapter, WINDSURF_CLIENT_ID, WINDSURF_PROVIDER_TYPE,
WINDSURF_SHOW_AUTH_TOKEN_REDIRECT, WINDSURF_SIGNIN_URL,
};

View File

@@ -0,0 +1,982 @@
use crate::core::{current_unix_secs, OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
use crate::provider::{
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthAdapter,
ProviderOAuthCapabilities, ProviderOAuthImportInput, ProviderOAuthProbeResult,
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const WINDSURF_PROVIDER_TYPE: &str = "windsurf";
pub const WINDSURF_SIGNIN_URL: &str = "https://windsurf.com/windsurf/signin";
pub const WINDSURF_CLIENT_ID: &str = "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u";
pub const WINDSURF_SHOW_AUTH_TOKEN_REDIRECT: &str = "show-auth-token";
const AUTH1_PASSWORD_LOGIN_URL: &str = "https://windsurf.com/_devin-auth/password/login";
const WINDSURF_POST_AUTH_URL: &str =
"https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth";
const WINDSURF_POST_AUTH_LEGACY_URL: &str =
"https://server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth";
const WINDSURF_REGISTER_USER_URL: &str =
"https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser";
const WINDSURF_REGISTER_USER_LEGACY_URL: &str = "https://api.codeium.com/register_user/";
#[derive(Debug, Clone, Default)]
pub struct WindsurfProviderOAuthAdapter;
impl WindsurfProviderOAuthAdapter {
async fn import_raw_api_key(
&self,
input: &ProviderOAuthImportInput,
api_key: &str,
auth_method: &str,
source: &str,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let api_key = api_key.trim();
if api_key.is_empty() {
return Err(OAuthError::invalid_request("windsurf api_key is required"));
}
let mut auth_config = Map::new();
auth_config.insert("provider_type".to_string(), json!(WINDSURF_PROVIDER_TYPE));
auth_config.insert("auth_method".to_string(), json!(auth_method));
auth_config.insert("register_source".to_string(), json!(source));
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
if let Some(name) = input.name.as_deref().and_then(non_empty_str) {
auth_config.insert("name".to_string(), json!(name));
}
if let Some(raw) = input.raw_credentials.as_ref() {
copy_optional_string(raw, &mut auth_config, "email", &["email"]);
copy_optional_string(
raw,
&mut auth_config,
"social_provider",
&["social_provider", "socialProvider"],
);
}
if auth_config.get("email").is_some() {
auth_config.insert("email_verified".to_string(), json!(false));
}
insert_secret_fingerprint(&mut auth_config, "credential_fingerprint", api_key);
Ok(provider_token_set(
api_key,
Value::Object(auth_config),
None,
))
}
async fn register_with_token(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: &ProviderOAuthImportInput,
token: &str,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let token = token.trim();
if token.is_empty() {
return Err(OAuthError::invalid_request("windsurf token is required"));
}
let mut errors = Vec::new();
for (url, source) in [
(WINDSURF_REGISTER_USER_URL, "new"),
(WINDSURF_REGISTER_USER_LEGACY_URL, "legacy"),
] {
let response = executor
.execute(OAuthHttpRequest {
request_id: format!("provider-oauth:windsurf-register:{source}"),
method: reqwest::Method::POST,
url: url.to_string(),
headers: json_connect_headers(),
content_type: Some("application/json".to_string()),
json_body: Some(json!({ "firebase_id_token": token })),
body_bytes: None,
network: ctx.network.clone(),
})
.await;
match response {
Ok(response) if (200..300).contains(&response.status_code) => {
let payload = response
.json_body
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
.ok_or_else(|| {
OAuthError::invalid_response("RegisterUser response is not json")
})?;
if let Some(api_key) = string_any(&payload, &["api_key", "apiKey"]) {
let mut auth_config = Map::new();
auth_config
.insert("provider_type".to_string(), json!(WINDSURF_PROVIDER_TYPE));
auth_config.insert("auth_method".to_string(), json!("token"));
auth_config.insert("register_source".to_string(), json!(source));
insert_secret_fingerprint(&mut auth_config, "id_token_fingerprint", token);
insert_secret_fingerprint(
&mut auth_config,
"credential_fingerprint",
&api_key,
);
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
copy_optional_string(&payload, &mut auth_config, "name", &["name"]);
let payload_email_verified = string_any(&payload, &["email"]).is_some();
copy_optional_string(&payload, &mut auth_config, "email", &["email"]);
copy_optional_string(
&payload,
&mut auth_config,
"account_id",
&["account_id", "accountId", "user_id", "userId"],
);
copy_optional_string(
&payload,
&mut auth_config,
"primary_org_id",
&[
"primary_org_id",
"primaryOrgId",
"organization_id",
"organizationId",
],
);
copy_optional_string(
&payload,
&mut auth_config,
"api_server_url",
&["api_server_url", "apiServerUrl"],
);
copy_optional_string(
&payload,
&mut auth_config,
"plan_name",
&["plan_name", "planName", "plan"],
);
if let Some(name) = input.name.as_deref().and_then(non_empty_str) {
auth_config
.entry("name".to_string())
.or_insert_with(|| json!(name));
}
if let Some(raw) = input.raw_credentials.as_ref() {
copy_optional_string(raw, &mut auth_config, "email", &["email"]);
copy_optional_string(
raw,
&mut auth_config,
"social_provider",
&["social_provider", "socialProvider"],
);
}
if auth_config.get("email").is_some() {
auth_config.insert(
"email_verified".to_string(),
json!(payload_email_verified),
);
}
return Ok(provider_token_set(
&api_key,
Value::Object(auth_config),
None,
));
}
errors.push(format!("{source}=missing api_key"));
}
Ok(response) => errors.push(format!(
"{source}=HTTP {} {}",
response.status_code,
truncate_body(&response.body_text)
)),
Err(error) => errors.push(format!("{source}={error}")),
}
}
Err(OAuthError::invalid_response(format!(
"RegisterUser failed: {}",
errors.join(" | ")
)))
}
async fn login_with_password(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: &ProviderOAuthImportInput,
email: &str,
password: &str,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let email = email.trim();
let password = password.trim();
if email.is_empty() || password.is_empty() {
return Err(OAuthError::invalid_request(
"windsurf email and password are required",
));
}
let login_response = executor
.execute(OAuthHttpRequest {
request_id: "provider-oauth:windsurf-auth1-login".to_string(),
method: reqwest::Method::POST,
url: AUTH1_PASSWORD_LOGIN_URL.to_string(),
headers: json_headers(),
content_type: Some("application/json".to_string()),
json_body: Some(json!({ "email": email, "password": password })),
body_bytes: None,
network: ctx.network.clone(),
})
.await?;
if !(200..300).contains(&login_response.status_code) {
return Err(OAuthError::HttpStatus {
status_code: login_response.status_code,
body_excerpt: truncate_body(&login_response.body_text),
});
}
let login_payload = login_response
.json_body
.or_else(|| serde_json::from_str::<Value>(&login_response.body_text).ok())
.ok_or_else(|| OAuthError::invalid_response("Auth1 response is not json"))?;
let auth1_token = string_any(&login_payload, &["token", "access_token", "accessToken"])
.ok_or_else(|| OAuthError::invalid_response("Auth1 response missing token"))?;
let mut post_auth_errors = Vec::new();
for (url, source) in [
(WINDSURF_POST_AUTH_URL, "new"),
(WINDSURF_POST_AUTH_LEGACY_URL, "legacy"),
] {
let mut headers = proto_headers();
headers.insert("x-devin-auth1-token".to_string(), auth1_token.clone());
let response = executor
.execute(OAuthHttpRequest {
request_id: format!("provider-oauth:windsurf-post-auth:{source}"),
method: reqwest::Method::POST,
url: url.to_string(),
headers,
content_type: Some("application/proto".to_string()),
json_body: None,
body_bytes: Some(Vec::new()),
network: ctx.network.clone(),
})
.await;
match response {
Ok(response) if (200..300).contains(&response.status_code) => {
let payload = response
.json_body
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
.ok_or_else(|| {
OAuthError::invalid_response("WindsurfPostAuth response is not json")
})?;
if let Some(session_token) = string_any(&payload, &["sessionToken"]) {
let mut auth_config = Map::new();
auth_config
.insert("provider_type".to_string(), json!(WINDSURF_PROVIDER_TYPE));
auth_config.insert("auth_method".to_string(), json!("email_password"));
auth_config.insert("register_source".to_string(), json!(source));
auth_config.insert("email".to_string(), json!(email));
auth_config.insert("email_verified".to_string(), json!(true));
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
copy_optional_string(
&payload,
&mut auth_config,
"account_id",
&["accountId", "account_id"],
);
copy_optional_string(
&payload,
&mut auth_config,
"primary_org_id",
&["primaryOrgId", "primary_org_id"],
);
copy_optional_string(
&payload,
&mut auth_config,
"api_server_url",
&["apiServerUrl", "api_server_url"],
);
copy_optional_string(
&payload,
&mut auth_config,
"plan_name",
&["planName", "plan_name", "plan"],
);
if let Some(name) = input.name.as_deref().and_then(non_empty_str) {
auth_config.insert("name".to_string(), json!(name));
}
insert_secret_fingerprint(
&mut auth_config,
"credential_fingerprint",
&session_token,
);
return Ok(provider_token_set(
&session_token,
Value::Object(auth_config),
None,
));
}
post_auth_errors.push(format!("{source}=missing sessionToken"));
}
Ok(response) => post_auth_errors.push(format!(
"{source}=HTTP {} {}",
response.status_code,
truncate_body(&response.body_text)
)),
Err(error) => post_auth_errors.push(format!("{source}={error}")),
}
}
Err(OAuthError::invalid_response(format!(
"WindsurfPostAuth failed: {}",
post_auth_errors.join(" | ")
)))
}
}
#[async_trait]
impl ProviderOAuthAdapter for WindsurfProviderOAuthAdapter {
fn provider_type(&self) -> &'static str {
WINDSURF_PROVIDER_TYPE
}
fn capabilities(&self) -> ProviderOAuthCapabilities {
ProviderOAuthCapabilities {
supports_authorization_code: false,
supports_refresh_token_import: true,
supports_batch_import: true,
supports_device_flow: true,
supports_account_probe: true,
rotates_refresh_token: false,
}
}
fn build_authorize_url(
&self,
_ctx: &ProviderOAuthTransportContext,
state: &str,
_code_challenge: Option<&str>,
) -> Result<OAuthAuthorizeResponse, OAuthError> {
let mut url = url::Url::parse(WINDSURF_SIGNIN_URL)
.map_err(|_| OAuthError::invalid_response("invalid windsurf signin url"))?;
{
let mut query = url.query_pairs_mut();
query.append_pair("response_type", "token");
query.append_pair("client_id", WINDSURF_CLIENT_ID);
query.append_pair("redirect_uri", WINDSURF_SHOW_AUTH_TOKEN_REDIRECT);
query.append_pair("state", state);
query.append_pair("prompt", "login");
query.append_pair("redirect_parameters_type", "query");
query.append_pair("workflow", "");
}
Ok(OAuthAuthorizeResponse {
authorize_url: url.to_string(),
state: state.to_string(),
code_challenge: None,
})
}
async fn import_credentials(
&self,
executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext,
input: ProviderOAuthImportInput,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
let raw = input.raw_credentials.as_ref();
if let Some(api_key) = raw.and_then(|value| string_any(value, &["api_key", "apiKey"])) {
return self
.import_raw_api_key(&input, &api_key, "api_key", "manual")
.await;
}
if let Some(api_key) = input
.refresh_token
.as_deref()
.and_then(|value| windsurf_raw_api_key(value).map(ToOwned::to_owned))
{
return self
.import_raw_api_key(&input, &api_key, "api_key", "manual")
.await;
}
if let Some(token) = raw.and_then(|value| {
string_any(
value,
&[
"token",
"auth_token",
"authToken",
"access_token",
"accessToken",
"refresh_token",
"refreshToken",
],
)
}) {
if windsurf_raw_api_key(&token).is_some() {
return self
.import_raw_api_key(&input, &token, "api_key", "manual")
.await;
}
return self
.register_with_token(executor, ctx, &input, &token)
.await;
}
if let Some(token) = input.refresh_token.as_deref().and_then(non_empty_str) {
return self.register_with_token(executor, ctx, &input, token).await;
}
if let (Some(email), Some(password)) = (
raw.and_then(|value| string_any(value, &["email"])),
raw.and_then(|value| string_any(value, &["password"])),
) {
return self
.login_with_password(executor, ctx, &input, &email, &password)
.await;
}
Err(OAuthError::invalid_request(
"windsurf credentials require api_key, token, or email/password",
))
}
async fn refresh(
&self,
_executor: &dyn OAuthHttpExecutor,
_ctx: &ProviderOAuthTransportContext,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthTokenSet, OAuthError> {
Ok(provider_token_set(
&account.access_token,
account.auth_config.clone(),
account.expires_at_unix_secs,
))
}
fn resolve_request_auth(
&self,
account: &ProviderOAuthAccount,
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
Ok(ProviderOAuthRequestAuth::Header {
name: "authorization".to_string(),
value: format!("Bearer {}", account.access_token.trim()),
})
}
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
Some(secret_fingerprint(&account.access_token))
}
async fn probe_account_state(
&self,
_executor: &dyn OAuthHttpExecutor,
_ctx: &ProviderOAuthTransportContext,
account: &ProviderOAuthAccount,
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
let metadata = account
.identity
.get(WINDSURF_PROVIDER_TYPE)
.cloned()
.or_else(|| account.auth_config.get(WINDSURF_PROVIDER_TYPE).cloned());
let email = string_any(&account.auth_config, &["email"])
.or_else(|| {
account
.identity
.get("email")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.or_else(|| {
metadata
.as_ref()
.and_then(|value| string_any(value, &["email"]))
});
let invalid_reason = string_any(
&account.auth_config,
&["oauth_invalid_reason", "invalid_reason"],
)
.or_else(|| {
metadata
.as_ref()
.and_then(|value| string_any(value, &["last_error", "invalid_reason"]))
});
Ok(Some(ProviderOAuthProbeResult {
state: ProviderOAuthAccountState {
is_valid: !account.access_token.trim().is_empty() && invalid_reason.is_none(),
email,
quota: metadata,
invalid_reason,
raw: Some(json!({
"auth_config": account.auth_config,
"identity": account.identity,
})),
},
}))
}
}
fn provider_token_set(
api_key: &str,
auth_config: Value,
expires_at_unix_secs: Option<u64>,
) -> ProviderOAuthTokenSet {
ProviderOAuthTokenSet {
token_set: OAuthTokenSet {
access_token: api_key.trim().to_string(),
refresh_token: None,
token_type: Some("windsurf_api_key".to_string()),
scope: None,
expires_at_unix_secs,
raw_payload: Some(json!({
"access_token": api_key.trim(),
"token_type": "windsurf_api_key",
})),
},
auth_config,
}
}
fn windsurf_raw_api_key(value: &str) -> Option<&str> {
let value = value.trim();
if value.starts_with("devin-session-token$") || value.starts_with("sk-") {
Some(value)
} else {
None
}
}
fn json_headers() -> BTreeMap<String, String> {
BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "application/json".to_string()),
("user-agent".to_string(), "windsurf/1.9600.41".to_string()),
])
}
fn json_connect_headers() -> BTreeMap<String, String> {
let mut headers = json_headers();
headers.insert("connect-protocol-version".to_string(), "1".to_string());
headers
}
fn proto_headers() -> BTreeMap<String, String> {
BTreeMap::from([
("content-type".to_string(), "application/proto".to_string()),
("accept".to_string(), "application/json".to_string()),
("connect-protocol-version".to_string(), "1".to_string()),
(
"referer".to_string(),
"https://windsurf.com/account/login".to_string(),
),
("user-agent".to_string(), "windsurf/1.9600.41".to_string()),
])
}
fn copy_optional_string(
value: &Value,
target: &mut Map<String, Value>,
key: &str,
aliases: &[&str],
) {
if let Some(text) = string_any(value, aliases) {
target.entry(key.to_string()).or_insert_with(|| json!(text));
}
}
fn string_any(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
value
.get(*key)
.and_then(Value::as_str)
.and_then(non_empty_str)
.map(ToOwned::to_owned)
})
}
fn insert_secret_fingerprint(target: &mut Map<String, Value>, key: &str, secret: &str) {
let secret = secret.trim();
if !secret.is_empty() {
target.insert(key.to_string(), json!(secret_fingerprint(secret)));
}
}
fn non_empty_str(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
fn truncate_body(body: &str) -> String {
let body = body.trim();
if body.is_empty() {
return "-".to_string();
}
if let Ok(mut value) = serde_json::from_str::<Value>(body) {
redact_sensitive_json(&mut value);
return value.to_string().chars().take(500).collect();
}
if contains_sensitive_marker(body) {
"[REDACTED upstream error body]".to_string()
} else {
body.chars().take(500).collect()
}
}
fn redact_sensitive_json(value: &mut Value) {
match value {
Value::Object(object) => {
for (key, value) in object {
if is_sensitive_key(key) {
*value = json!("[REDACTED]");
} else {
redact_sensitive_json(value);
}
}
}
Value::Array(items) => {
for item in items {
redact_sensitive_json(item);
}
}
Value::String(text) if looks_like_sensitive_secret(text) => {
*text = "[REDACTED]".to_string();
}
_ => {}
}
}
fn is_sensitive_key(key: &str) -> bool {
let normalized = key
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
normalized.contains("token")
|| normalized.contains("apikey")
|| normalized.contains("password")
|| normalized.contains("authorization")
|| normalized.contains("secret")
}
fn looks_like_sensitive_secret(value: &str) -> bool {
let value = value.trim();
value.starts_with("devin-session-token$")
|| value.starts_with("sk-")
|| (value.len() > 80 && value.split('.').count() == 3)
}
fn contains_sensitive_marker(value: &str) -> bool {
let value = value.to_ascii_lowercase();
[
"token",
"api_key",
"apikey",
"password",
"authorization",
"sessiontoken",
"firebase_id_token",
"idtoken",
"secret",
]
.iter()
.any(|marker| value.contains(marker))
}
fn secret_fingerprint(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
let mut fingerprint = String::with_capacity(16);
for byte in digest.iter().take(8) {
use std::fmt::Write as _;
let _ = write!(&mut fingerprint, "{byte:02x}");
}
fingerprint
}
#[cfg(test)]
mod tests {
use super::{
secret_fingerprint, truncate_body, WindsurfProviderOAuthAdapter, AUTH1_PASSWORD_LOGIN_URL,
WINDSURF_POST_AUTH_URL, WINDSURF_REGISTER_USER_URL,
};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::{
ProviderOAuthAdapter, ProviderOAuthImportInput, ProviderOAuthTransportContext,
};
use async_trait::async_trait;
use serde_json::json;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct RecordingExecutor {
requests: Arc<Mutex<Vec<OAuthHttpRequest>>>,
}
#[async_trait]
impl OAuthHttpExecutor for RecordingExecutor {
async fn execute(
&self,
request: OAuthHttpRequest,
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
self.requests
.lock()
.expect("requests lock")
.push(request.clone());
if request.url == WINDSURF_REGISTER_USER_URL {
return Ok(OAuthHttpResponse {
status_code: 200,
body_text: r#"{"apiKey":"sk-ws-01-registered","name":"Alice","email":"alice@example.com","accountId":"acct-1","primaryOrgId":"org-1","planName":"Pro","apiServerUrl":"https://server.codeium.com"}"#.to_string(),
json_body: Some(json!({
"apiKey": "sk-ws-01-registered",
"name": "Alice",
"email": "alice@example.com",
"accountId": "acct-1",
"primaryOrgId": "org-1",
"planName": "Pro",
"apiServerUrl": "https://server.codeium.com"
})),
});
}
if request.url == AUTH1_PASSWORD_LOGIN_URL {
return Ok(OAuthHttpResponse {
status_code: 200,
body_text: r#"{"token":"auth1-token"}"#.to_string(),
json_body: Some(json!({"token": "auth1-token"})),
});
}
if request.url == WINDSURF_POST_AUTH_URL {
return Ok(OAuthHttpResponse {
status_code: 200,
body_text: r#"{"sessionToken":"devin-session-token$password","accountId":"acct-password","primaryOrgId":"org-password","planName":"Pro"}"#.to_string(),
json_body: Some(json!({
"sessionToken": "devin-session-token$password",
"accountId": "acct-password",
"primaryOrgId": "org-password",
"planName": "Pro"
})),
});
}
Ok(OAuthHttpResponse {
status_code: 200,
body_text: "{}".to_string(),
json_body: Some(json!({})),
})
}
}
fn ctx() -> ProviderOAuthTransportContext {
ProviderOAuthTransportContext {
provider_id: "provider-windsurf".to_string(),
provider_type: "windsurf".to_string(),
endpoint_id: None,
key_id: None,
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: None,
endpoint_config: None,
key_config: None,
network: crate::network::OAuthNetworkContext::provider_operation(None),
}
}
#[tokio::test]
async fn imports_raw_api_key_without_network() {
let executor = RecordingExecutor::default();
let adapter = WindsurfProviderOAuthAdapter;
let result = adapter
.import_credentials(
&executor,
&ctx(),
ProviderOAuthImportInput {
provider_type: "windsurf".to_string(),
name: Some("Alice".to_string()),
refresh_token: None,
raw_credentials: Some(json!({
"api_key": "devin-session-token$abc",
"email": "alice@example.com"
})),
network: crate::network::OAuthNetworkContext::provider_operation(None),
},
)
.await
.expect("api key should import");
assert_eq!(result.token_set.access_token, "devin-session-token$abc");
assert_eq!(result.auth_config["auth_method"], json!("api_key"));
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
assert_eq!(result.auth_config["email_verified"], json!(false));
assert_eq!(
result.auth_config["credential_fingerprint"],
json!(secret_fingerprint("devin-session-token$abc"))
);
assert!(executor.requests.lock().expect("requests lock").is_empty());
}
#[tokio::test]
async fn exchanges_show_auth_token_with_register_user() {
let executor = RecordingExecutor::default();
let adapter = WindsurfProviderOAuthAdapter;
let result = adapter
.import_credentials(
&executor,
&ctx(),
ProviderOAuthImportInput {
provider_type: "windsurf".to_string(),
name: None,
refresh_token: None,
raw_credentials: Some(json!({
"token": "firebase-id-token",
"email": "alice@example.com"
})),
network: crate::network::OAuthNetworkContext::provider_operation(None),
},
)
.await
.expect("token should register");
assert_eq!(result.token_set.access_token, "sk-ws-01-registered");
assert_eq!(result.auth_config["auth_method"], json!("token"));
assert_eq!(result.auth_config["register_source"], json!("new"));
assert!(result.auth_config.get("id_token").is_none());
assert_eq!(
result.auth_config["id_token_fingerprint"],
json!(secret_fingerprint("firebase-id-token"))
);
assert_eq!(
result.auth_config["credential_fingerprint"],
json!(secret_fingerprint("sk-ws-01-registered"))
);
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
assert_eq!(result.auth_config["email_verified"], json!(true));
assert_eq!(result.auth_config["account_id"], json!("acct-1"));
assert_eq!(result.auth_config["primary_org_id"], json!("org-1"));
assert_eq!(result.auth_config["plan_name"], json!("Pro"));
let requests = executor.requests.lock().expect("requests lock");
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0]
.json_body
.as_ref()
.and_then(|body| body.get("firebase_id_token"))
.and_then(serde_json::Value::as_str),
Some("firebase-id-token")
);
}
#[tokio::test]
async fn imports_email_password_without_storing_password() {
let executor = RecordingExecutor::default();
let adapter = WindsurfProviderOAuthAdapter;
let result = adapter
.import_credentials(
&executor,
&ctx(),
ProviderOAuthImportInput {
provider_type: "windsurf".to_string(),
name: None,
refresh_token: None,
raw_credentials: Some(json!({
"email": "alice@example.com",
"password": "secret-password"
})),
network: crate::network::OAuthNetworkContext::provider_operation(None),
},
)
.await
.expect("email password should import");
assert_eq!(
result.token_set.access_token,
"devin-session-token$password"
);
assert_eq!(result.auth_config["auth_method"], json!("email_password"));
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
assert_eq!(result.auth_config["email_verified"], json!(true));
assert_eq!(result.auth_config["account_id"], json!("acct-password"));
assert_eq!(result.auth_config["primary_org_id"], json!("org-password"));
assert_eq!(result.auth_config["plan_name"], json!("Pro"));
assert_eq!(
result.auth_config["credential_fingerprint"],
json!(secret_fingerprint("devin-session-token$password"))
);
assert!(result.auth_config.get("password").is_none());
let requests = executor.requests.lock().expect("requests lock");
assert_eq!(requests.len(), 2);
assert_eq!(
requests[0]
.json_body
.as_ref()
.and_then(|body| body.get("password"))
.and_then(serde_json::Value::as_str),
Some("secret-password")
);
assert_eq!(
requests[1]
.headers
.get("x-devin-auth1-token")
.map(String::as_str),
Some("auth1-token")
);
}
#[tokio::test]
async fn imports_session_token_from_token_field_without_register_user() {
let executor = RecordingExecutor::default();
let adapter = WindsurfProviderOAuthAdapter;
let result = adapter
.import_credentials(
&executor,
&ctx(),
ProviderOAuthImportInput {
provider_type: "windsurf".to_string(),
name: None,
refresh_token: None,
raw_credentials: Some(json!({
"token": "devin-session-token$abc",
"email": "alice@example.com"
})),
network: crate::network::OAuthNetworkContext::provider_operation(None),
},
)
.await
.expect("session token should import directly");
assert_eq!(result.token_set.access_token, "devin-session-token$abc");
assert_eq!(result.auth_config["auth_method"], json!("api_key"));
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
assert_eq!(
result.auth_config["credential_fingerprint"],
json!(secret_fingerprint("devin-session-token$abc"))
);
assert!(executor.requests.lock().expect("requests lock").is_empty());
}
#[tokio::test]
async fn imports_session_token_from_access_token_alias_without_register_user() {
let executor = RecordingExecutor::default();
let adapter = WindsurfProviderOAuthAdapter;
let result = adapter
.import_credentials(
&executor,
&ctx(),
ProviderOAuthImportInput {
provider_type: "windsurf".to_string(),
name: None,
refresh_token: None,
raw_credentials: Some(json!({
"access_token": "devin-session-token$alias",
"email": "alice@example.com"
})),
network: crate::network::OAuthNetworkContext::provider_operation(None),
},
)
.await
.expect("session token alias should import directly");
assert_eq!(result.token_set.access_token, "devin-session-token$alias");
assert_eq!(result.auth_config["auth_method"], json!("api_key"));
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
assert!(executor.requests.lock().expect("requests lock").is_empty());
}
#[test]
fn windsurf_error_body_redacts_sensitive_fields() {
let body = truncate_body(
r#"{"error":"invalid","firebase_id_token":"firebase-id-token","sessionToken":"devin-session-token$abc","nested":{"apiKey":"sk-secret"}}"#,
);
assert!(body.contains("[REDACTED]"));
assert!(!body.contains("firebase-id-token"));
assert!(!body.contains("devin-session-token$abc"));
assert!(!body.contains("sk-secret"));
}
}

View File

@@ -19,13 +19,14 @@ impl ProviderOAuthService {
pub fn with_builtin_adapters() -> Self {
use super::providers::{
AntigravityProviderOAuthAdapter, CodexProviderOAuthAdapter,
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter,
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter, WindsurfProviderOAuthAdapter,
};
let mut service = Self::new()
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()))
.with_adapter(Arc::new(WindsurfProviderOAuthAdapter));
for provider_type in ["claude_code", "chatgpt_web", "gemini_cli"] {
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
service = service.with_adapter(Arc::new(adapter));
@@ -128,6 +129,7 @@ mod tests {
"gemini_cli",
"antigravity",
"kiro",
"windsurf",
] {
assert!(
service.adapter(provider_type).is_ok(),

View File

@@ -17,14 +17,18 @@ pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
pub use providers::{
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
build_codex_pool_quota_request, build_kiro_pool_quota_request,
enrich_chatgpt_web_quota_metadata, grok_mode_id_for_model, grok_pool_tier_from_quota_bucket,
grok_quota_window_key_for_model, grok_supported_quota_windows_for_tier,
normalize_chatgpt_web_image_quota_limit, AntigravityProviderPoolAdapter,
ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter, DefaultProviderPoolAdapter,
GrokProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
build_windsurf_pool_rate_limit_request_with_base_url, enrich_chatgpt_web_quota_metadata,
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroPoolQuotaAuthInput,
KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL,
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION, WINDSURF_MODEL_CONFIGS_PATH,
WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
};
pub use quota::{
provider_pool_key_account_quota_exhausted, provider_pool_key_scheduling_label,
@@ -69,7 +73,8 @@ mod tests {
"gemini_cli",
"grok",
"kiro",
"vertex_ai"
"vertex_ai",
"windsurf"
]
);
assert!(service
@@ -85,11 +90,12 @@ mod tests {
assert_eq!(
service.provider_types_for_capability(ProviderPoolCapability::QuotaRefresh),
["antigravity", "chatgpt_web", "codex", "grok", "kiro"]
["antigravity", "chatgpt_web", "codex", "grok", "kiro", "windsurf"]
);
assert!(service.supports_quota_refresh("codex"));
assert!(service.supports_quota_refresh("antigravity"));
assert!(service.supports_quota_refresh("grok"));
assert!(service.supports_quota_refresh("windsurf"));
assert!(!service.supports_quota_refresh("gemini_cli"));
assert_eq!(
service.quota_refresh_unsupported_message("claude_code"),
@@ -238,6 +244,110 @@ mod tests {
assert_eq!(metadata["image_quota_used"], json!(33.0));
}
#[test]
fn windsurf_quota_request_uses_user_status_connect_rpc() {
let spec = build_windsurf_pool_quota_request("key-ws", "session-token-123");
assert_eq!(spec.request_id, "windsurf-quota:key-ws");
assert_eq!(spec.method, "POST");
assert_eq!(
spec.url,
format!("https://server.codeium.com{WINDSURF_USER_STATUS_PATH}")
);
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
assert_eq!(
spec.headers
.get("connect-protocol-version")
.map(String::as_str),
Some("1")
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.pointer("/metadata/apiKey"))
.and_then(Value::as_str),
Some("session-token-123")
);
assert_eq!(spec.provider_api_format, "windsurf:user_status");
}
#[test]
fn windsurf_model_and_rate_limit_requests_use_connect_rpc_metadata() {
let models = build_windsurf_pool_model_configs_request("key-ws", "api-key-123");
let rate_limit = build_windsurf_pool_rate_limit_request("key-ws", "api-key-123");
assert_eq!(
models.url,
format!("https://server.codeium.com{WINDSURF_MODEL_CONFIGS_PATH}")
);
assert_eq!(
rate_limit.url,
format!("https://server.codeium.com{WINDSURF_RATE_LIMIT_PATH}")
);
for spec in [models, rate_limit] {
assert_eq!(spec.method, "POST");
assert_eq!(
spec.headers
.get("connect-protocol-version")
.map(String::as_str),
Some("1")
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.pointer("/metadata/apiKey"))
.and_then(Value::as_str),
Some("api-key-123")
);
assert_eq!(spec.client_api_format, "openai:chat");
}
}
#[test]
fn windsurf_rate_limit_metadata_keeps_member_schedulable() {
let service = ProviderPoolService::with_builtin_adapters();
let key = sample_key(Some(json!({
"windsurf": {
"updated_at": 1_700_000_000u64,
"rate_limit": {
"limited": true,
"retry_after_ms": 60_000
}
}
})));
let signals = service.member_signals("windsurf", &key, None);
assert!(!signals.quota_exhausted);
}
#[test]
fn windsurf_status_snapshot_ban_marks_member_exhausted() {
let service = ProviderPoolService::with_builtin_adapters();
let mut key = sample_key(Some(json!({
"windsurf": {
"updated_at": 1_700_000_000u64,
"daily_remaining_percent": 100.0
}
})));
key.status_snapshot = Some(json!({
"quota": {
"provider_type": "windsurf",
"code": "banned",
"exhausted": false,
"windows": [{
"code": "daily",
"used_ratio": 0.0,
"remaining_ratio": 1.0
}]
}
}));
let signals = service.member_signals("windsurf", &key, None);
assert!(signals.quota_exhausted);
}
#[test]
fn preset_payload_derives_provider_support_from_capabilities() {
let payload = build_admin_pool_scheduling_presets_payload();
@@ -251,10 +361,14 @@ mod tests {
.find(|item| item["name"] == "recent_refresh")
.expect("recent_refresh should exist");
assert_eq!(free_first["providers"], json!(["codex", "grok", "kiro"]));
assert_eq!(
free_first["providers"],
json!(["codex", "grok", "kiro", "windsurf"])
);
assert_eq!(
recent_refresh["providers"],
json!(["codex", "grok", "kiro"])
json!(["codex", "grok", "kiro", "windsurf"])
);
);
}

View File

@@ -5,6 +5,7 @@ pub mod default;
pub mod grok;
pub mod kiro;
pub mod unsupported;
pub mod windsurf;
pub use antigravity::AntigravityProviderPoolAdapter;
pub use antigravity::{
@@ -32,3 +33,11 @@ pub use unsupported::{
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
};
pub use windsurf::{
build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
build_windsurf_pool_rate_limit_request_with_base_url, WindsurfProviderPoolAdapter,
WINDSURF_DEFAULT_BASE_URL, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
WINDSURF_USER_STATUS_PATH,
};

View File

@@ -0,0 +1,286 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_pool_core::PoolSchedulingPreset;
use serde_json::{json, Map, Value};
use crate::capability::ProviderPoolCapabilities;
use crate::provider::{
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
ProviderPoolMemberInput,
};
use crate::quota::{
provider_pool_json_bool, provider_pool_json_f64, provider_pool_member_quota_snapshot,
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const WINDSURF_DEFAULT_BASE_URL: &str = "https://server.codeium.com";
pub const WINDSURF_USER_STATUS_PATH: &str =
"/exa.seat_management_pb.SeatManagementService/GetUserStatus";
pub const WINDSURF_MODEL_CONFIGS_PATH: &str =
"/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs";
pub const WINDSURF_RATE_LIMIT_PATH: &str =
"/exa.api_server_pb.ApiServerService/CheckUserMessageRateLimit";
#[derive(Debug, Clone, Default)]
pub struct WindsurfProviderPoolAdapter;
impl ProviderPoolAdapter for WindsurfProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"windsurf"
}
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities {
plan_tier: true,
quota_reset: true,
quota_refresh: true,
}
}
fn default_scheduling_presets(&self) -> Vec<PoolSchedulingPreset> {
vec![PoolSchedulingPreset {
preset: "recent_refresh".to_string(),
enabled: true,
mode: None,
}]
}
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
if windsurf_quota_snapshot_hard_exhausted(input.key, input.provider_type) {
return true;
}
if let Some(exhausted) =
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
{
return exhausted;
}
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
.is_some_and(windsurf_quota_exhausted_from_bucket)
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
provider_pool_endpoint_format_matches(endpoint, "openai:chat")
})
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 openai:chat 端点".to_string()
}
}
fn windsurf_quota_snapshot_hard_exhausted(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> bool {
provider_pool_member_quota_snapshot(key, provider_type)
.and_then(|quota| quota.get("code"))
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.is_some_and(|code| matches!(code.as_str(), "banned" | "forbidden" | "quarantined"))
}
pub fn build_windsurf_pool_quota_request(
key_id: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_pool_quota_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
}
pub fn build_windsurf_pool_quota_request_with_base_url(
key_id: &str,
base_url: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_connect_rpc_request(
format!("windsurf-quota:{key_id}"),
"windsurf:user_status",
"windsurf-user-status",
base_url,
WINDSURF_USER_STATUS_PATH,
api_key,
)
}
pub fn build_windsurf_pool_model_configs_request(
key_id: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_pool_model_configs_request_with_base_url(
key_id,
WINDSURF_DEFAULT_BASE_URL,
api_key,
)
}
pub fn build_windsurf_pool_model_configs_request_with_base_url(
key_id: &str,
base_url: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_connect_rpc_request(
format!("windsurf-models:{key_id}"),
"windsurf:model_configs",
"windsurf-model-configs",
base_url,
WINDSURF_MODEL_CONFIGS_PATH,
api_key,
)
}
pub fn build_windsurf_pool_rate_limit_request(
key_id: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_pool_rate_limit_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
}
pub fn build_windsurf_pool_rate_limit_request_with_base_url(
key_id: &str,
base_url: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_connect_rpc_request(
format!("windsurf-rate-limit:{key_id}"),
"windsurf:rate_limit",
"windsurf-rate-limit",
base_url,
WINDSURF_RATE_LIMIT_PATH,
api_key,
)
}
fn build_windsurf_connect_rpc_request(
request_id: String,
provider_api_format: &str,
model_name: &str,
base_url: &str,
path: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
let mut headers = BTreeMap::new();
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "application/json".to_string());
headers.insert("connect-protocol-version".to_string(), "1".to_string());
headers.insert("user-agent".to_string(), "windsurf/1.9600.41".to_string());
ProviderPoolQuotaRequestSpec {
request_id,
provider_name: "windsurf".to_string(),
quota_kind: "windsurf".to_string(),
method: "POST".to_string(),
url: format!("{}{}", base_url.trim_end_matches('/'), path),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"metadata": windsurf_metadata(api_key),
})),
client_api_format: "openai:chat".to_string(),
provider_api_format: provider_api_format.to_string(),
model_name: Some(model_name.to_string()),
accept_invalid_certs: false,
}
}
fn windsurf_metadata(api_key: &str) -> Value {
json!({
"apiKey": api_key,
"ideName": "windsurf",
"ideVersion": "1.9600.41",
"extensionName": "windsurf",
"extensionVersion": "1.9600.41",
"locale": "en",
})
}
pub(crate) fn windsurf_quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_json_bool(bucket.get("banned"))
.or_else(|| provider_pool_json_bool(bucket.get("quarantined")))
.unwrap_or(false)
{
return true;
}
let daily_remaining = provider_pool_json_f64(bucket.get("daily_remaining_percent"));
let weekly_remaining = provider_pool_json_f64(bucket.get("weekly_remaining_percent"));
daily_remaining.is_some_and(|value| value <= 0.0)
|| weekly_remaining.is_some_and(|value| value <= 0.0)
}
#[cfg(test)]
mod tests {
use super::{windsurf_quota_exhausted_from_bucket, windsurf_quota_snapshot_hard_exhausted};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::json;
fn sample_key_with_quota(code: &str, exhausted: bool) -> StoredProviderCatalogKey {
let mut key = StoredProviderCatalogKey::new(
"key-windsurf".to_string(),
"provider-windsurf".to_string(),
"windsurf@example.com".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("sample key should build");
key.status_snapshot = Some(json!({
"quota": {
"provider_type": "windsurf",
"code": code,
"exhausted": exhausted,
"windows": [{
"code": "daily",
"used_ratio": 0.0,
"remaining_ratio": 1.0
}]
}
}));
key
}
#[test]
fn windsurf_rate_limit_bucket_does_not_mark_quota_exhausted() {
let bucket = json!({
"rate_limit": {
"limited": true,
"retry_after_ms": 60_000u64
},
"daily_remaining_percent": 50.0,
"weekly_remaining_percent": 50.0
});
let bucket = bucket.as_object().expect("bucket should be object");
assert!(!windsurf_quota_exhausted_from_bucket(bucket));
}
#[test]
fn windsurf_banned_and_quarantined_snapshot_codes_are_hard_exhausted() {
for code in ["banned", "forbidden", "quarantined"] {
let key = sample_key_with_quota(code, false);
assert!(
windsurf_quota_snapshot_hard_exhausted(&key, "windsurf"),
"{code} should be hard exhausted"
);
}
let cooldown_key = sample_key_with_quota("cooldown", false);
assert!(!windsurf_quota_snapshot_hard_exhausted(
&cooldown_key,
"windsurf"
));
let rate_limited_key = sample_key_with_quota("rate_limited", false);
assert!(!windsurf_quota_snapshot_hard_exhausted(
&rate_limited_key,
"windsurf"
));
}
}

View File

@@ -13,6 +13,7 @@ use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
use crate::providers::{
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroProviderPoolAdapter,
WindsurfProviderPoolAdapter,
CLAUDE_CODE_PROVIDER_POOL_ADAPTER, GEMINI_CLI_PROVIDER_POOL_ADAPTER,
VERTEX_AI_PROVIDER_POOL_ADAPTER,
};
@@ -54,6 +55,7 @@ impl ProviderPoolService {
.with_adapter(Arc::new(GrokProviderPoolAdapter))
.with_adapter(Arc::new(KiroProviderPoolAdapter))
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
.with_adapter(Arc::new(WindsurfProviderPoolAdapter))
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
}

View File

@@ -24,6 +24,7 @@ mod standard;
pub mod url;
pub mod vertex;
mod video;
pub mod windsurf;
pub use aether_oauth as oauth;
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
@@ -131,3 +132,9 @@ pub use video::{
resolve_video_create_auth, video_create_transport_unsupported_reason,
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput, VideoTaskTransportSnapshotLookup,
};
pub use windsurf::{
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
build_windsurf_cascade_upstream_url, is_windsurf_provider_transport,
local_windsurf_request_transport_unsupported_reason_with_network, GET_CHAT_MESSAGE_PATH,
WINDSURF_ENVELOPE_NAME,
};

View File

@@ -253,6 +253,17 @@ const GROK_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
..STANDARD_RUNTIME_POLICY
};
const WINDSURF_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
fixed_provider: true,
api_format_inheritance: ProviderApiFormatInheritance::OAuthOrBearer,
enable_format_conversion_by_default: true,
oauth_is_bearer_like: true,
supports_model_fetch: false,
supports_local_openai_chat_transport: false,
supports_local_same_format_transport: false,
..STANDARD_RUNTIME_POLICY
};
const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
provider_type: "claude_code",
version: 1,
@@ -405,6 +416,19 @@ const GROK_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplat
runtime_policy: GROK_RUNTIME_POLICY,
};
const WINDSURF_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
provider_type: "windsurf",
version: 1,
base_url: "https://server.codeium.com",
endpoints: &[FixedProviderEndpointTemplate {
item_key: "openai:chat",
api_format: "openai:chat",
custom_path: None,
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
}],
runtime_policy: WINDSURF_RUNTIME_POLICY,
};
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
provider_runtime_policy(provider_type).fixed_provider
}
@@ -455,6 +479,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
"gemini_cli" => Some(&GEMINI_CLI_FIXED_PROVIDER_TEMPLATE),
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
"antigravity" => Some(&ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE),
"windsurf" => Some(&WINDSURF_FIXED_PROVIDER_TEMPLATE),
_ => None,
}
}
@@ -565,6 +590,17 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
redirect_uri: "http://localhost:51121/oauth2callback",
use_pkce: true,
}),
"windsurf" => Some(ProviderOAuthTemplate {
provider_type: "windsurf",
display_name: "Windsurf",
authorize_url: "https://windsurf.com/windsurf/signin",
token_url: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser",
client_id: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u",
client_secret: "",
scopes: &[],
redirect_uri: "show-auth-token",
use_pkce: false,
}),
_ => None,
}
}
@@ -575,16 +611,18 @@ pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] = &[
"chatgpt_web",
"gemini_cli",
"antigravity",
"windsurf",
];
#[cfg(test)]
mod tests {
use super::{
fixed_provider_endpoint_template_by_api_format, fixed_provider_key_inherits_api_formats,
fixed_provider_template, provider_runtime_policy,
fixed_provider_template, provider_runtime_policy, provider_type_admin_oauth_template,
provider_type_allows_auth_channel_mismatch_by_default, provider_type_oauth_is_bearer_like,
provider_type_supports_local_embedding_transport,
provider_type_supports_local_same_format_transport, FixedProviderEndpointConfigValue,
ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES,
};
#[test]
@@ -675,6 +713,47 @@ mod tests {
assert!(!template.runtime_policy.supports_local_same_format_transport);
}
#[test]
fn windsurf_fixed_provider_template_exposes_openai_chat() {
let template = fixed_provider_template("windsurf").expect("windsurf template should exist");
assert_eq!(template.provider_type, "windsurf");
assert_eq!(template.base_url, "https://server.codeium.com");
assert_eq!(template.version, 1);
assert_eq!(
template
.endpoints
.iter()
.map(|item| item.api_format)
.collect::<Vec<_>>(),
vec!["openai:chat"]
);
assert!(
fixed_provider_endpoint_template_by_api_format("windsurf", "openai:chat").is_some()
);
let policy = provider_runtime_policy("windsurf");
assert!(policy.fixed_provider);
assert!(policy.enable_format_conversion_by_default);
assert!(policy.oauth_is_bearer_like);
assert!(!policy.supports_model_fetch);
assert!(!policy.supports_local_same_format_transport);
}
#[test]
fn windsurf_admin_oauth_template_is_advertised() {
let template =
provider_type_admin_oauth_template("windsurf").expect("windsurf oauth template");
assert_eq!(template.provider_type, "windsurf");
assert_eq!(template.display_name, "Windsurf");
assert_eq!(
template.authorize_url,
"https://windsurf.com/windsurf/signin"
);
assert_eq!(template.redirect_uri, "show-auth-token");
assert!(ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES.contains(&"windsurf"));
}
#[test]
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
assert!(fixed_provider_key_inherits_api_formats(

View File

@@ -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: "windsurf@example.com".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()
))
);
}
}

View File

@@ -1739,7 +1739,7 @@ fn clone_usage_capture_value(value: Option<&Value>) -> Option<Value> {
}
fn clone_usage_body_value(value: Option<&Value>) -> Option<Value> {
value.cloned()
value.cloned().map(mask_sensitive_body_fields)
}
fn sanitize_usage_event_capture_fields(mut data: UsageEventData) -> UsageEventData {
@@ -2057,6 +2057,52 @@ fn mask_sensitive_headers_in_json_value(value: Option<Value>) -> Option<Value> {
Some(value)
}
fn mask_sensitive_body_fields(mut value: Value) -> Value {
mask_sensitive_body_fields_in_place(&mut value);
value
}
fn mask_sensitive_body_fields_in_place(value: &mut Value) {
match value {
Value::Object(object) => {
for (key, value) in object.iter_mut() {
if is_sensitive_body_key(key) {
let replacement = if value.is_null() {
Value::Null
} else if let Some(text) = value.as_str() {
Value::String(mask_sensitive_header_value(text))
} else {
Value::String(mask_sensitive_header_value(&value.to_string()))
};
*value = replacement;
} else {
mask_sensitive_body_fields_in_place(value);
}
}
}
Value::Array(items) => {
for item in items {
mask_sensitive_body_fields_in_place(item);
}
}
_ => {}
}
}
fn is_sensitive_body_key(key: &str) -> bool {
let normalized = key
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
normalized.contains("token")
|| normalized.contains("apikey")
|| normalized.contains("password")
|| normalized.contains("authorization")
|| normalized.contains("secret")
|| normalized == "cookie"
}
fn resolve_error_category(status_code: u16, event_type: UsageEventType) -> Option<String> {
match event_type {
UsageEventType::Cancelled => Some("cancelled".to_string()),
@@ -2970,10 +3016,10 @@ mod tests {
build_sync_terminal_usage_seed, build_terminal_usage_context_seed,
build_terminal_usage_event_from_seed, build_usage_event_data_seed,
extract_token_counts_from_json, extract_token_counts_from_value, headers_to_json,
mask_header_value, mask_sensitive_headers_in_json_value, parse_sse_body_for_storage,
resolve_error_message, trim_owned_non_empty_string, LifecycleUsageSeed, TerminalUsageSeed,
UsageBodyRefsSeed, UsageBodyStatesSeed, UsageRoutingSeed, UsageTerminalState,
MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
mask_header_value, mask_sensitive_body_fields, mask_sensitive_headers_in_json_value,
parse_sse_body_for_storage, resolve_error_message, trim_owned_non_empty_string,
LifecycleUsageSeed, TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed,
UsageRoutingSeed, UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
};
use crate::{
build_upsert_usage_record_from_event, GatewayStreamReportRequest, GatewaySyncReportRequest,
@@ -3378,6 +3424,114 @@ mod tests {
);
}
#[test]
fn usage_body_capture_redacts_nested_provider_secrets() {
let masked = mask_sensitive_body_fields(json!({
"metadata": {
"apiKey": "devin-session-token$secret-value",
"nested": {
"sessionToken": "session-token-secret"
}
},
"password": "plain-password",
"messages": [{"content": "safe text"}]
}));
assert_ne!(
masked.pointer("/metadata/apiKey").and_then(Value::as_str),
Some("devin-session-token$secret-value")
);
assert_ne!(
masked
.pointer("/metadata/nested/sessionToken")
.and_then(Value::as_str),
Some("session-token-secret")
);
assert_ne!(
masked.get("password").and_then(Value::as_str),
Some("plain-password")
);
assert_eq!(
masked
.pointer("/messages/0/content")
.and_then(Value::as_str),
Some("safe text")
);
}
#[test]
fn sync_terminal_usage_redacts_provider_request_body_secrets_from_context() {
let plan = ExecutionPlan {
request_id: "req-sync-redact-provider-request-1".to_string(),
candidate_id: Some("cand-sync-redact-provider-request-1".to_string()),
provider_name: Some("Windsurf".to_string()),
provider_id: "provider-windsurf".to_string(),
endpoint_id: "endpoint-windsurf".to_string(),
key_id: "key-windsurf".to_string(),
method: "POST".to_string(),
url: "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage"
.to_string(),
headers: BTreeMap::new(),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(json!({"model": "windsurf-model"})),
stream: false,
client_api_format: "openai:chat".to_string(),
provider_api_format: "openai:chat".to_string(),
model_name: Some("windsurf-model".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let payload = GatewaySyncReportRequest {
trace_id: "trace-sync-redact-provider-request-1".to_string(),
report_kind: "openai_chat_sync_success".to_string(),
report_context: Some(json!({
"client_api_format": "openai:chat",
"provider_api_format": "openai:chat",
"provider_request_body": {
"metadata": {
"apiKey": "devin-session-token$abc",
"sessionToken": "session-token-secret"
},
"message": "safe prompt"
}
})),
status_code: 200,
headers: BTreeMap::new(),
body_json: Some(json!({"id": "resp_1", "choices": []})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let event =
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("terminal usage should build");
let provider_request = event
.data
.provider_request_body
.as_ref()
.expect("provider request body should be captured");
assert_ne!(
provider_request
.pointer("/metadata/apiKey")
.and_then(Value::as_str),
Some("devin-session-token$abc")
);
assert_ne!(
provider_request
.pointer("/metadata/sessionToken")
.and_then(Value::as_str),
Some("session-token-secret")
);
assert_eq!(
provider_request.pointer("/message").and_then(Value::as_str),
Some("safe prompt")
);
}
#[test]
fn builds_stream_terminal_usage_from_provider_body_and_preserves_client_body() {
let plan = ExecutionPlan {