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

@@ -4,7 +4,7 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use aether_contracts::ResolvedTransportProfile;
use serde_json::Value;
use serde_json::{json, Value};
use crate::ai_serving::planner::candidate_preparation::{
prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth,
@@ -16,9 +16,10 @@ use crate::ai_serving::planner::common::{
request_requires_body_stream_field, OPENAI_CHAT_STREAM_PLAN_KIND,
};
use crate::ai_serving::planner::standard::{
apply_codex_openai_responses_special_headers, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, request_body_build_failure_extra_data,
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
build_local_openai_chat_request_body, build_local_openai_chat_upstream_url,
request_body_build_failure_extra_data,
};
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
use crate::ai_serving::transport::kiro::{
@@ -27,10 +28,18 @@ use crate::ai_serving::transport::kiro::{
KIRO_ENVELOPE_NAME,
};
use crate::ai_serving::transport::local_openai_chat_transport_unsupported_reason;
use crate::ai_serving::transport::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,
resolve_windsurf_cascade_auth, WINDSURF_ENVELOPE_NAME,
};
use crate::ai_serving::transport::{
build_grok_browser_headers, build_grok_upstream_url, build_kiro_cross_format_upstream_url,
build_standard_provider_request_headers, GrokHeaderInput, StandardProviderRequestHeadersInput,
GROK_CHAT_PATH,
build_openai_image_headers, build_openai_image_upstream_url,
build_standard_provider_request_headers, openai_image_transport_unsupported_reason,
resolve_openai_image_auth, GrokHeaderInput, ProviderOpenAiImageHeadersInput,
StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
};
use crate::ai_serving::{
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
@@ -331,6 +340,25 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
}));
}
if provider_api_format == "openai:chat" && is_windsurf_provider_transport(transport) {
return build_windsurf_openai_chat_payload_parts(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
decision_kind,
report_kind,
transport,
upstream_is_stream,
redaction.redacted,
)
.await;
}
if provider_api_format == "openai:chat" {
if let Some(skip_reason) = local_openai_chat_transport_unsupported_reason(transport) {
mark_skipped_local_openai_chat_candidate(
@@ -498,6 +526,20 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
};
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
if provider_api_format == "openai:image" {
return resolve_openai_chat_to_openai_image_payload_parts(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
upstream_is_stream,
)
.await;
}
let Some(conversion_kind) =
request_conversion_kind("openai:chat", provider_api_format.as_str())
@@ -789,6 +831,624 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
}))
}
#[allow(clippy::too_many_arguments)]
async fn resolve_openai_chat_to_openai_image_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
upstream_is_stream: bool,
) -> Result<Option<LocalOpenAiChatCandidatePayloadParts>, GatewayError> {
let candidate = &eligible.candidate;
let transport = &eligible.transport;
let provider_api_format = "openai:image";
if let Some(skip_reason) =
openai_image_transport_unsupported_reason(transport, provider_api_format)
{
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
let prepared_candidate = match prepare_header_authenticated_candidate(
crate::ai_serving::PlannerAppState::new(state),
transport,
candidate,
resolve_openai_image_auth(transport),
OauthPreparationContext {
trace_id,
api_format: provider_api_format,
operation: "openai_chat_image_bridge",
},
)
.await
{
Ok(prepared) => prepared,
Err(skip_reason) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let is_chatgpt_web = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web");
let Some((mut provider_request_body, image_request_summary)) = (if is_chatgpt_web {
build_chatgpt_web_image_provider_body_from_openai_chat_body(
body_json,
&input.requested_model,
)
} else {
build_openai_image_provider_body_from_openai_chat_body(
body_json,
&input.requested_model,
upstream_is_stream,
)
}) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(body_json, "openai:chat", provider_api_format),
)
.await;
return Ok(None);
};
if !is_chatgpt_web {
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
provider_api_format,
transport.endpoint.body_rules.as_ref(),
Some(candidate.key_id.as_str()),
);
}
let upstream_url = if is_chatgpt_web {
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
} else {
build_openai_image_upstream_url(transport, parts.uri.query())
};
let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
headers: &parts.headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: body_json,
})
else {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
CandidateFailureDiagnostic::header_rules_apply_failed(
"openai:chat",
provider_api_format,
"openai_chat_image_bridge_headers",
),
)
.await;
return Ok(None);
};
if is_chatgpt_web {
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
} else {
apply_codex_openai_responses_special_headers(
&mut provider_request_headers,
&provider_request_body,
&parts.headers,
transport.provider.provider_type.as_str(),
provider_api_format,
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
}
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats("openai:chat", provider_api_format);
Ok(Some(LocalOpenAiChatCandidatePayloadParts {
client_api_format: "openai:chat".to_string(),
auth_header: prepared_candidate.auth_header,
auth_value: prepared_candidate.auth_value,
mapped_model: prepared_candidate.mapped_model,
provider_api_format: provider_api_format.to_string(),
provider_request_body,
provider_request_headers,
upstream_url,
execution_strategy,
conversion_mode,
report_kind: "openai_chat_stream_success".to_string(),
envelope_name: None,
transport: Arc::clone(transport),
request_redacted: false,
transport_profile: None,
image_request_summary: Some(image_request_summary),
}))
}
fn build_openai_image_provider_body_from_openai_chat_body(
body_json: &Value,
requested_model: &str,
upstream_is_stream: bool,
) -> Option<(Value, Value)> {
let (prompt, images) = collect_openai_chat_image_prompt_and_images(body_json)?;
let operation = if images.is_empty() {
"generate"
} else {
"edit"
};
let mut image_options = serde_json::Map::new();
copy_openai_chat_image_option(body_json, &mut image_options, "size");
copy_openai_chat_image_option(body_json, &mut image_options, "quality");
copy_openai_chat_image_option(body_json, &mut image_options, "background");
copy_openai_chat_image_option(body_json, &mut image_options, "output_format");
copy_openai_chat_image_option(body_json, &mut image_options, "output_compression");
copy_openai_chat_image_option(body_json, &mut image_options, "moderation");
copy_openai_chat_image_option(body_json, &mut image_options, "input_fidelity");
copy_openai_chat_image_option(body_json, &mut image_options, "partial_images");
let input = if images.is_empty() {
serde_json::json!([{
"role": "user",
"content": prompt,
}])
} else {
let mut content = vec![serde_json::json!({
"type": "input_text",
"text": prompt,
})];
content.extend(images);
serde_json::json!([{
"role": "user",
"content": content,
}])
};
let mut body = serde_json::Map::new();
if let Some(model) = body_json
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
let requested_model = requested_model.trim();
(!requested_model.is_empty()).then_some(requested_model)
})
{
body.insert("model".to_string(), Value::String(model.to_string()));
}
body.insert("input".to_string(), input);
let mut image_tool = image_options.clone();
image_tool.insert(
"type".to_string(),
Value::String("image_generation".to_string()),
);
body.insert(
"tools".to_string(),
Value::Array(vec![Value::Object(image_tool)]),
);
if upstream_is_stream {
body.insert("stream".to_string(), Value::Bool(true));
}
if let Some(user) = body_json
.get("user")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert("user".to_string(), Value::String(user.to_string()));
}
let mut summary = serde_json::Map::new();
summary.insert(
"operation".to_string(),
Value::String(operation.to_string()),
);
for key in ["output_format", "partial_images", "size", "quality"] {
if let Some(value) = image_options.get(key) {
summary.insert(key.to_string(), value.clone());
}
}
Some((Value::Object(body), Value::Object(summary)))
}
fn build_chatgpt_web_image_provider_body_from_openai_chat_body(
body_json: &Value,
requested_model: &str,
) -> Option<(Value, Value)> {
let (prompt, images) = collect_openai_chat_image_prompt_and_images(body_json)?;
let operation = if images.is_empty() {
"generate"
} else {
"edit"
};
let size = body_json
.get("size")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1024x1024");
let output_format = body_json
.get("output_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("png");
let quality = body_json
.get("quality")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("medium");
let model = body_json
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| requested_model.trim());
let web_model = body_json
.get("web_model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("gpt-5-5-thinking");
let image_urls = openai_image_inputs_as_urls(&images);
let body = json!({
"operation": operation,
"model": if model.is_empty() { "gpt-image-2" } else { model },
"web_model": web_model,
"prompt": prompt,
"size": size,
"ratio": chatgpt_web_ratio_for_size(size),
"output_format": output_format,
"images": image_urls,
});
let summary = json!({
"operation": operation,
"output_format": output_format,
"size": size,
"quality": quality,
});
Some((body, summary))
}
fn copy_openai_chat_image_option(
body_json: &Value,
image_options: &mut serde_json::Map<String, Value>,
key: &str,
) {
if let Some(value) = body_json.get(key) {
image_options.insert(key.to_string(), value.clone());
}
}
fn collect_openai_chat_image_prompt_and_images(body_json: &Value) -> Option<(String, Vec<Value>)> {
let messages = body_json.get("messages").and_then(Value::as_array)?;
let mut prompt_parts = Vec::new();
let mut images = Vec::new();
for message in messages.iter().filter_map(Value::as_object) {
let role = message
.get("role")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let content = message.get("content");
if matches!(role, "system" | "developer" | "user") {
if let Some(text) = crate::ai_serving::extract_openai_text_content(content)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
prompt_parts.push(text);
}
}
if role == "user" {
collect_openai_chat_image_inputs(content, &mut images);
}
}
let prompt = prompt_parts.join("\n").trim().to_string();
(!prompt.is_empty()).then_some((prompt, images))
}
fn collect_openai_chat_image_inputs(content: Option<&Value>, images: &mut Vec<Value>) {
let Some(parts) = content.and_then(Value::as_array) else {
return;
};
for part in parts.iter().filter_map(Value::as_object) {
let part_type = part
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if matches!(part_type, "image_url" | "input_image") {
if let Some(url) = part
.get("image_url")
.and_then(|value| {
value
.as_str()
.or_else(|| value.get("url").and_then(Value::as_str))
})
.map(str::trim)
.filter(|value| !value.is_empty())
{
images.push(serde_json::json!({
"type": "input_image",
"image_url": url,
}));
} else if let Some(file_id) = part
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
images.push(serde_json::json!({
"type": "input_image",
"file_id": file_id,
}));
}
}
}
}
fn openai_image_inputs_as_urls(images: &[Value]) -> Vec<Value> {
images
.iter()
.filter_map(|image| {
image
.get("image_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| Value::String(value.to_string()))
})
.collect()
}
fn chatgpt_web_ratio_for_size(size: &str) -> String {
let Some((width, height)) = size.split_once('x') else {
return "1:1".to_string();
};
let Ok(width) = width.trim().parse::<u64>() else {
return "1:1".to_string();
};
let Ok(height) = height.trim().parse::<u64>() else {
return "1:1".to_string();
};
if width == 0 || height == 0 {
return "1:1".to_string();
}
let divisor = gcd(width, height);
format!("{}:{}", width / divisor, height / divisor)
}
fn gcd(mut left: u64, mut right: u64) -> u64 {
while right != 0 {
let next = left % right;
left = right;
right = next;
}
left.max(1)
}
fn chatgpt_web_image_internal_url(base_url: &str) -> String {
let base_url = base_url.trim().trim_end_matches('/');
let base_url = if base_url.is_empty() {
"https://chatgpt.com"
} else {
base_url
};
format!("{base_url}/__aether/chatgpt-web-image")
}
#[allow(clippy::too_many_arguments)]
async fn build_windsurf_openai_chat_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
decision_kind: &str,
report_kind: &str,
transport: &Arc<GatewayProviderTransportSnapshot>,
upstream_is_stream: bool,
request_redacted: bool,
) -> Result<Option<LocalOpenAiChatCandidatePayloadParts>, GatewayError> {
let planner_state = crate::ai_serving::PlannerAppState::new(state);
let candidate = &eligible.candidate;
if let Some(skip_reason) =
local_windsurf_request_transport_unsupported_reason_with_network(transport)
{
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
let prepared_candidate = match prepare_header_authenticated_candidate(
planner_state,
transport,
candidate,
resolve_windsurf_cascade_auth(transport)
.or_else(|| resolve_local_openai_bearer_auth(transport)),
OauthPreparationContext {
trace_id,
api_format: "openai:chat",
operation: "openai_chat_windsurf_cascade",
},
)
.await
{
Ok(prepared) => prepared,
Err(skip_reason) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let Some(provider_request_body) = build_windsurf_cascade_request_body(
body_json,
&prepared_candidate.mapped_model,
&prepared_candidate.auth_value,
transport.endpoint.body_rules.as_ref(),
Some(&parts.headers),
upstream_is_stream,
) else {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
CandidateFailureDiagnostic::envelope_build_failed(
"openai:chat",
"openai:chat",
"openai_chat_windsurf_cascade",
),
)
.await;
return Ok(None);
};
let Some(upstream_url) = build_windsurf_cascade_upstream_url(
transport.endpoint.base_url.as_str(),
parts.uri.query(),
) else {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
CandidateFailureDiagnostic::upstream_url_missing(
"openai:chat",
"openai:chat",
"openai_chat_windsurf_url",
),
)
.await;
return Ok(None);
};
let mut provider_request_headers = match build_windsurf_cascade_headers(
&parts.headers,
&provider_request_body,
body_json,
transport.endpoint.header_rules.as_ref(),
&prepared_candidate.auth_header,
&prepared_candidate.auth_value,
upstream_is_stream,
) {
Some(headers) => headers,
None => {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
CandidateFailureDiagnostic::header_rules_apply_failed(
"openai:chat",
"openai:chat",
"openai_chat_windsurf_headers",
),
)
.await;
return Ok(None);
}
};
request_identity_response_encoding_when_redacted(
&mut provider_request_headers,
request_redacted,
);
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats("openai:chat", "openai:chat");
let resolved_report_kind =
if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND || !upstream_is_stream {
report_kind.to_string()
} else {
"openai_chat_sync_finalize".to_string()
};
Ok(Some(LocalOpenAiChatCandidatePayloadParts {
client_api_format: "openai:chat".to_string(),
auth_header: prepared_candidate.auth_header,
auth_value: prepared_candidate.auth_value,
mapped_model: prepared_candidate.mapped_model,
provider_api_format: "openai:chat".to_string(),
provider_request_body,
provider_request_headers,
upstream_url,
execution_strategy,
conversion_mode,
report_kind: resolved_report_kind,
envelope_name: Some(WINDSURF_ENVELOPE_NAME),
transport: Arc::clone(transport),
request_redacted,
transport_profile: None,
image_request_summary: None,
}))
}
#[allow(clippy::too_many_arguments)]
async fn build_kiro_openai_chat_cross_format_payload_parts(
state: &AppState,
@@ -1012,3 +1672,71 @@ fn redaction_mask_error_to_gateway_error(error: RedactionMaskError) -> GatewayEr
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chatgpt_web_chat_image_bridge_body_uses_internal_web_shape() {
let body_json = json!({
"model": "gpt-image-2",
"messages": [
{"role": "system", "content": "Use crisp vector-like shapes."},
{
"role": "user",
"content": [
{"type": "text", "text": "Draw a glass city"},
{"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}}
]
}
],
"size": "1536x1024",
"output_format": "webp",
"web_model": "gpt-5-image-test"
});
let (provider_body, summary) =
build_chatgpt_web_image_provider_body_from_openai_chat_body(&body_json, "gpt-image-2")
.expect("chat image body should convert");
assert_eq!(provider_body["operation"], "edit");
assert_eq!(provider_body["model"], "gpt-image-2");
assert_eq!(provider_body["web_model"], "gpt-5-image-test");
assert_eq!(
provider_body["prompt"],
"Use crisp vector-like shapes.\nDraw a glass city"
);
assert_eq!(provider_body["size"], "1536x1024");
assert_eq!(provider_body["ratio"], "3:2");
assert_eq!(provider_body["output_format"], "webp");
assert_eq!(provider_body["images"][0], "https://example.com/ref.png");
assert_eq!(summary["operation"], "edit");
assert_eq!(summary["output_format"], "webp");
}
#[test]
fn openai_chat_image_bridge_body_injects_image_generation_tool() {
let body_json = json!({
"model": "gpt-image-2",
"messages": [
{"role": "user", "content": "Draw a glass city"}
],
"size": "1024x1024",
"output_format": "png"
});
let (provider_body, summary) =
build_openai_image_provider_body_from_openai_chat_body(&body_json, "gpt-image-2", true)
.expect("chat image body should convert");
assert_eq!(provider_body["tools"][0]["type"], "image_generation");
assert_eq!(provider_body["tools"][0]["size"], "1024x1024");
assert_eq!(provider_body["tools"][0]["output_format"], "png");
assert_eq!(provider_body["model"], "gpt-image-2");
assert_eq!(provider_body["stream"], true);
assert_eq!(provider_body["input"][0]["content"], "Draw a glass city");
assert_eq!(summary["operation"], "generate");
assert_eq!(summary["output_format"], "png");
}
}

View File

@@ -50,6 +50,10 @@ pub(crate) mod vertex {
pub(crate) use aether_provider_transport::vertex::*;
}
pub(crate) mod windsurf {
pub(crate) use aether_provider_transport::windsurf::*;
}
pub(crate) use aether_provider_transport::{
append_transport_diagnostics_to_value, apply_local_body_rules,
apply_local_body_rules_with_request_headers, apply_local_header_rules,
@@ -69,12 +73,16 @@ pub(crate) use aether_provider_transport::{
build_standard_plan_fallback_openai_responses_url, build_standard_provider_request_headers,
build_transport_request_url, build_transport_request_url_for_request_body,
build_video_create_headers, build_video_create_request_body, build_video_create_upstream_url,
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
build_windsurf_cascade_upstream_url,
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
classify_same_format_provider_request_behavior, ensure_upstream_auth_header,
gemini_files_transport_unsupported_reason, header_rules_are_locally_supported,
header_rules_have_enabled_rules, local_gemini_transport_unsupported_reason_with_network,
header_rules_have_enabled_rules, is_windsurf_provider_transport,
local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason,
local_standard_transport_unsupported_reason_with_network,
local_windsurf_request_transport_unsupported_reason_with_network,
openai_image_transport_unsupported_reason, request_conversion_direct_auth,
request_conversion_enabled_for_transport, request_conversion_transport_supported,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
@@ -97,4 +105,5 @@ pub(crate) use aether_provider_transport::{
StandardPlanFallbackHeadersInput, StandardProviderRequestHeaders,
StandardProviderRequestHeadersInput, TransportRequestBodySemanticsError,
TransportRequestUrlParams, GROK_CHAT_PATH, GROK_INTERNAL_HEADER, GROK_RATE_LIMITS_PATH,
WINDSURF_ENVELOPE_NAME,
};

View File

@@ -29,6 +29,10 @@ use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate}
use crate::GatewayError;
use aether_admin::provider::oauth::parse_admin_provider_oauth_kiro_batch_import_entries;
use aether_contracts::ProxySnapshot;
use aether_oauth::core::OAuthError;
use aether_oauth::provider::{
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
};
use serde_json::{json, Map, Value};
struct AdminProviderOAuthResolvedBatchImport {
@@ -37,6 +41,16 @@ struct AdminProviderOAuthResolvedBatchImport {
expires_at: Option<u64>,
}
fn sanitize_windsurf_batch_import_error(error: &OAuthError) -> String {
match error {
OAuthError::InvalidRequest(_) => "Windsurf 凭据验证失败: 请求参数无效".to_string(),
OAuthError::HttpStatus { status_code, .. } => {
format!("Windsurf 凭据验证失败: HTTP {status_code}")
}
_ => "Windsurf 凭据验证失败".to_string(),
}
}
pub(super) fn estimate_admin_provider_oauth_batch_import_total(
provider_type: &str,
raw_credentials: &str,
@@ -98,6 +112,65 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
.map(str::trim)
.filter(|value| !value.is_empty());
if provider_type.eq_ignore_ascii_case("windsurf") {
let token_for_import = refresh_token.or(access_token);
let ctx = ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: provider_type.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: aether_oauth::network::OAuthNetworkContext::provider_operation(
request_proxy.clone(),
),
};
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
let result = ProviderOAuthService::with_builtin_adapters()
.import_credentials(
&executor,
&ctx,
ProviderOAuthImportInput {
provider_type: provider_type.to_string(),
name: entry
.raw_credentials
.as_ref()
.and_then(|raw| raw.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
refresh_token: token_for_import.map(ToOwned::to_owned),
raw_credentials: entry.raw_credentials.clone(),
network: ctx.network.clone(),
},
)
.await
.map_err(|error| sanitize_windsurf_batch_import_error(&error))?;
let access_token = result.token_set.access_token.trim().to_string();
if access_token.is_empty() {
return Err("Windsurf 凭据验证返回缺少 apiKey/sessionToken".to_string());
}
let auth_config = result
.auth_config
.as_object()
.cloned()
.ok_or_else(|| "Windsurf 凭据验证返回缺少 auth_config".to_string())?;
return Ok(AdminProviderOAuthResolvedBatchImport {
access_token,
auth_config,
expires_at: result.token_set.expires_at_unix_secs,
});
}
let Some(template) = template else {
return Err(ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL.to_string());
};
if let Some(refresh_token) = refresh_token {
let Some(template) = template else {
if provider_type_supports_access_token_import(provider_type) {
@@ -228,6 +301,25 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
};
let template = admin_provider_oauth_template(provider_type);
if template.is_none() && !provider_type.eq_ignore_ascii_case("windsurf") {
return Ok(AdminProviderOAuthBatchImportOutcome {
total: entries.len(),
success: 0,
failed: entries.len(),
results: entries
.iter()
.enumerate()
.map(|(index, _)| {
json!({
"index": index,
"status": "error",
"error": ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL,
"replaced": false,
})
})
.collect(),
});
}
let endpoint_resolution =
resolve_provider_oauth_runtime_endpoints(state, &provider, provider_type).await?;
@@ -251,6 +343,25 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
let mut failed = 0usize;
for (index, entry) in entries.iter().enumerate() {
if let Some(error) = entry.parse_error.as_ref() {
failed += 1;
results.push(json!({
"index": index,
"status": "error",
"error": error,
"replaced": false,
}));
maybe_report_admin_provider_oauth_batch_import_progress(
&mut progress,
entries.len(),
success,
failed,
&results,
)
.await;
continue;
}
let resolved_import = match resolve_admin_provider_oauth_batch_import_tokens(
state,
template,
@@ -418,3 +529,33 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
results,
})
}
#[cfg(test)]
mod tests {
use super::sanitize_windsurf_batch_import_error;
use aether_oauth::core::OAuthError;
#[test]
fn windsurf_batch_import_error_redacts_http_body() {
let error = OAuthError::HttpStatus {
status_code: 401,
body_excerpt: "sessionToken=devin-session-token$secret".to_string(),
};
let detail = sanitize_windsurf_batch_import_error(&error);
assert_eq!(detail, "Windsurf 凭据验证失败: HTTP 401");
assert!(!detail.contains("devin-session-token$secret"));
}
#[test]
fn windsurf_batch_import_error_redacts_provider_detail() {
let error = OAuthError::invalid_response("apiKey=sk-secret token=secret-token");
let detail = sanitize_windsurf_batch_import_error(&error);
assert_eq!(detail, "Windsurf 凭据验证失败");
assert!(!detail.contains("sk-secret"));
assert!(!detail.contains("secret-token"));
}
}

View File

@@ -60,6 +60,13 @@ pub(in super::super) async fn handle_admin_provider_oauth_batch_import(
"该 Provider 不是固定类型,无法使用 provider-oauth",
));
}
if provider_type != "kiro"
&& provider_type != "windsurf"
&& admin_provider_oauth_template(&provider_type).is_none()
{
return Ok(build_admin_provider_oauth_backend_unavailable_response());
}
let total = estimate_admin_provider_oauth_batch_import_total(
&provider_type,
payload.credentials.as_str(),

View File

@@ -19,8 +19,10 @@ pub(super) struct AdminProviderOAuthBatchImportRequest {
#[derive(Debug, Clone)]
pub(super) struct AdminProviderOAuthBatchImportEntry {
pub parse_error: Option<String>,
pub refresh_token: Option<String>,
pub access_token: Option<String>,
pub raw_credentials: Option<serde_json::Value>,
pub expires_at: Option<u64>,
pub account_id: Option<String>,
pub account_user_id: Option<String>,
@@ -141,8 +143,10 @@ fn extract_admin_provider_oauth_batch_import_entry(
access_token.as_deref(),
);
Some(AdminProviderOAuthBatchImportEntry {
parse_error: None,
refresh_token,
access_token,
raw_credentials: None,
expires_at: None,
account_id: None,
account_user_id: None,
@@ -160,6 +164,8 @@ fn extract_admin_provider_oauth_batch_import_entry(
}
}
serde_json::Value::Object(object) => {
let is_grok = provider_type.trim().eq_ignore_ascii_case("grok");
let is_windsurf = provider_type.trim().eq_ignore_ascii_case("windsurf");
let refresh_token = coerce_admin_provider_oauth_import_str(
object
.get("refresh_token")
@@ -170,12 +176,12 @@ fn extract_admin_provider_oauth_batch_import_entry(
.get("access_token")
.or_else(|| object.get("accessToken")),
);
let grok_token_alias = if provider_type.trim().eq_ignore_ascii_case("grok") {
let grok_token_alias = if is_grok {
object.get("token")
} else {
None
};
let grok_cookie = if provider_type.trim().eq_ignore_ascii_case("grok") {
let grok_cookie = if is_grok {
coerce_admin_provider_oauth_import_str(
object.get("cookie").or_else(|| object.get("cookieHeader")),
)
@@ -198,9 +204,39 @@ fn extract_admin_provider_oauth_batch_import_entry(
refresh_token.as_deref(),
access_token.as_deref().or(session_token.as_deref()),
);
if refresh_token.is_none() && access_token.is_none() {
let windsurf_api_key = is_windsurf.then(|| {
coerce_admin_provider_oauth_import_str(
object.get("api_key").or_else(|| object.get("apiKey")),
)
}).flatten();
let windsurf_token = is_windsurf.then(|| {
coerce_admin_provider_oauth_import_str(
object
.get("token")
.or_else(|| object.get("auth_token"))
.or_else(|| object.get("authToken")),
)
}).flatten();
let windsurf_password = is_windsurf
.then(|| coerce_admin_provider_oauth_import_str(object.get("password")))
.flatten();
let raw_credentials = if is_windsurf
&& (windsurf_api_key.is_some()
|| windsurf_token.is_some()
|| windsurf_password.is_some())
{
Some(item.clone())
} else {
None
};
if refresh_token.is_none() && access_token.is_none() && raw_credentials.is_none() {
return None;
}
let refresh_token = if is_windsurf {
refresh_token.or(windsurf_api_key).or(windsurf_token)
} else {
refresh_token
};
let expires_at =
json_u64_value(object.get("expires_at").or_else(|| object.get("expiresAt")));
let account_id = coerce_admin_provider_oauth_import_str(
@@ -285,8 +321,10 @@ fn extract_admin_provider_oauth_batch_import_entry(
.or_else(|| object.get("impersonate")),
);
Some(AdminProviderOAuthBatchImportEntry {
parse_error: None,
refresh_token,
access_token,
raw_credentials,
expires_at,
account_id,
account_user_id,
@@ -316,14 +354,17 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
}
if raw.starts_with('[') {
if let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(raw)
{
return items
.iter()
.filter_map(|item| {
extract_admin_provider_oauth_batch_import_entry(provider_type, item)
})
.collect();
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(serde_json::Value::Array(items)) => {
return items
.iter()
.filter_map(|item| {
extract_admin_provider_oauth_batch_import_entry(provider_type, item)
})
.collect();
}
Ok(_) => {}
Err(error) => return vec![parse_error_entry(format!("JSON 数组解析失败: {error}"))],
}
}
@@ -340,23 +381,61 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
raw.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(|line| {
if line.starts_with('{') {
return serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|value| {
extract_admin_provider_oauth_batch_import_entry(provider_type, &value)
});
.filter_map(|token| {
if is_json_like_batch_line(token) {
match serde_json::from_str::<serde_json::Value>(token) {
Ok(value @ serde_json::Value::Object(_)) => {
return extract_admin_provider_oauth_batch_import_entry(
provider_type,
&value,
);
}
Ok(_) => {
return Some(parse_error_entry(
"JSON 行必须是账号对象,不能作为 raw token 导入".to_string(),
));
}
Err(error) => {
return Some(parse_error_entry(format!("JSON 行解析失败: {error}")));
}
}
}
extract_admin_provider_oauth_batch_import_entry(
provider_type,
&serde_json::Value::String(line.to_string()),
&serde_json::Value::String(token.to_string()),
)
})
.collect()
}
fn parse_error_entry(error: String) -> AdminProviderOAuthBatchImportEntry {
AdminProviderOAuthBatchImportEntry {
parse_error: Some(error),
refresh_token: None,
access_token: None,
raw_credentials: None,
expires_at: None,
account_id: None,
account_user_id: None,
plan_type: None,
pool_tier: None,
user_id: None,
email: None,
account_name: None,
sso_rw_token: None,
cf_cookies: None,
cf_clearance: None,
user_agent: None,
browser_profile: None,
}
}
fn is_json_like_batch_line(line: &str) -> bool {
let line = line.trim_start();
line.starts_with('{') || line.starts_with('[')
}
pub(super) fn apply_admin_provider_oauth_batch_import_hints(
provider_type: &str,
entry: &AdminProviderOAuthBatchImportEntry,
@@ -633,4 +712,115 @@ mod tests {
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
assert_eq!(entries[0].pool_tier.as_deref(), Some("heavy"));
}
#[test]
fn parses_windsurf_json_credentials_for_native_import() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"windsurf",
r#"[
{"api_key":"devin-session-token$abc","email":"a@example.com"},
{"token":"firebase-id-token","name":"Browser Login"},
{"email":"b@example.com","password":"secret"},
{"access_token":"devin-session-token$alias","email":"c@example.com"}
]"#,
);
assert_eq!(entries.len(), 4);
assert_eq!(
entries[0].refresh_token.as_deref(),
Some("devin-session-token$abc")
);
assert_eq!(entries[0].email.as_deref(), Some("a@example.com"));
assert_eq!(
entries[0]
.raw_credentials
.as_ref()
.and_then(|value| value.get("api_key")),
Some(&json!("devin-session-token$abc"))
);
assert_eq!(
entries[1]
.raw_credentials
.as_ref()
.and_then(|value| value.get("token")),
Some(&json!("firebase-id-token"))
);
assert_eq!(
entries[2]
.raw_credentials
.as_ref()
.and_then(|value| value.get("password")),
Some(&json!("secret"))
);
assert_eq!(
entries[3].access_token.as_deref(),
Some("devin-session-token$alias")
);
}
#[test]
fn parses_windsurf_json_lines_credentials_for_native_import() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"windsurf",
r#"{"api_key":"devin-session-token$abc","email":"a@example.com"}
{"token":"firebase-id-token","name":"Browser Login"}
{"email":"b@example.com","password":"secret"}"#,
);
assert_eq!(entries.len(), 3);
assert_eq!(
entries[0]
.raw_credentials
.as_ref()
.and_then(|value| value.get("api_key")),
Some(&json!("devin-session-token$abc"))
);
assert_eq!(
entries[1]
.raw_credentials
.as_ref()
.and_then(|value| value.get("token")),
Some(&json!("firebase-id-token"))
);
assert_eq!(
entries[2]
.raw_credentials
.as_ref()
.and_then(|value| value.get("password")),
Some(&json!("secret"))
);
}
#[test]
fn invalid_json_line_is_parse_error_not_token() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"windsurf",
r#"{"email":"b@example.com","password":"secret""#,
);
assert_eq!(entries.len(), 1);
assert!(entries[0].parse_error.is_some());
assert!(entries[0].refresh_token.is_none());
assert!(entries[0].access_token.is_none());
assert!(entries[0].raw_credentials.is_none());
}
#[test]
fn json_like_line_after_token_is_parse_error_not_token() {
let entries = parse_admin_provider_oauth_batch_import_entries(
"windsurf",
"devin-session-token$abc\n[not-json",
);
assert_eq!(entries.len(), 2);
assert!(entries[0].parse_error.is_none());
assert_eq!(
entries[0].refresh_token.as_deref(),
Some("devin-session-token$abc")
);
assert!(entries[1].parse_error.is_some());
assert!(entries[1].refresh_token.is_none());
assert!(entries[1].access_token.is_none());
assert!(entries[1].raw_credentials.is_none());
}
}

View File

@@ -124,6 +124,13 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
"该 Provider 不是固定类型,无法使用 provider-oauth",
));
}
if provider_type != "kiro"
&& provider_type != "windsurf"
&& admin_provider_oauth_template(&provider_type).is_none()
{
return Ok(build_admin_provider_oauth_backend_unavailable_response());
}
let total = estimate_admin_provider_oauth_batch_import_total(
&provider_type,
payload.credentials.as_str(),

View File

@@ -12,6 +12,7 @@ use crate::GatewayError;
use aether_data::repository::provider_oauth::{
StoredAdminProviderOAuthDeviceSession, KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS,
};
use aether_oauth::provider::{ProviderOAuthService, ProviderOAuthTransportContext};
use axum::{
body::{Body, Bytes},
http,
@@ -28,6 +29,8 @@ const KIRO_SOCIAL_MANUAL_CALLBACK_PORT: u16 = 49153;
const KIRO_SOCIAL_ALLOWED_CALLBACK_PORTS: &[u16] = &[
3128, 4649, 6588, 8008, 9091, 49153, 50153, 51153, 52153, 53153,
];
const WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS: u64 = 600;
const WINDSURF_BROWSER_AUTH_POLL_INTERVAL_SECS: u64 = 5;
fn normalize_kiro_device_auth_type(raw: Option<&str>) -> String {
match raw
@@ -119,6 +122,26 @@ fn build_kiro_social_authorization_url(
)
}
fn build_windsurf_authorization_url(authorize_url: &str, login_option: &str) -> String {
let login_option = login_option.trim();
if login_option.is_empty() {
return authorize_url.to_string();
}
if let Ok(mut url) = Url::parse(authorize_url) {
url.query_pairs_mut()
.append_pair("login_option", login_option);
return url.to_string();
}
let separator = if authorize_url.contains('?') {
'&'
} else {
'?'
};
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair("login_option", login_option);
format!("{authorize_url}{separator}{}", serializer.finish())
}
pub(super) async fn handle_admin_provider_oauth_device_authorize(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -163,14 +186,14 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
));
};
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
if provider_type != "kiro" {
if provider_type != "kiro" && provider_type != "windsurf" {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"设备授权仅支持 Kiro provider",
"设备授权仅支持 Kiro / Windsurf provider",
));
}
let endpoint_resolution =
resolve_provider_oauth_runtime_endpoints(state, &provider, "kiro").await?;
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
let runtime_endpoint = endpoint_resolution.runtime_endpoint;
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
@@ -184,6 +207,103 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
)
.await;
if provider_type == "windsurf" {
let session_id = generate_provider_oauth_nonce();
let login_option = payload
.login_option
.as_deref()
.or(payload.auth_type.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default")
.to_ascii_lowercase();
let ctx = ProviderOAuthTransportContext {
provider_id: provider_id.clone(),
provider_type: provider_type.clone(),
endpoint_id: runtime_endpoint
.as_ref()
.map(|endpoint| endpoint.id.clone()),
key_id: None,
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: provider.config.clone(),
endpoint_config: runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.config.clone()),
key_config: None,
network: aether_oauth::network::OAuthNetworkContext::provider_operation(
request_proxy.clone(),
),
};
let mut authorization = match ProviderOAuthService::with_builtin_adapters()
.build_authorize_url(&ctx, &session_id, None)
{
Ok(authorization) => authorization,
Err(error) => {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
format!("Windsurf 授权 URL 构建失败: {error}"),
));
}
};
authorization.authorize_url =
build_windsurf_authorization_url(&authorization.authorize_url, &login_option);
let now_unix_secs = current_unix_secs();
let session = StoredAdminProviderOAuthDeviceSession {
provider_id: provider_id.clone(),
region: String::new(),
client_id: String::new(),
client_secret: String::new(),
device_code: String::new(),
auth_type: Some("browser".to_string()),
social_provider: Some(login_option.clone()),
code_verifier: None,
redirect_uri: Some("show-auth-token".to_string()),
machine_id: Some(uuid::Uuid::new_v4().to_string().to_ascii_lowercase()),
interval: WINDSURF_BROWSER_AUTH_POLL_INTERVAL_SECS,
expires_at_unix_secs: now_unix_secs
.saturating_add(WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS),
status: "pending".to_string(),
proxy_node_id: payload
.proxy_node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
created_at_unix_ms: now_unix_secs,
key_id: None,
email: None,
replaced: false,
error_msg: None,
};
if let Err(response) = state
.save_provider_oauth_device_session(
&session_id,
&session,
WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS
.saturating_add(KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS),
)
.await
{
return Ok(response);
}
return Ok(Json(json!({
"session_id": session_id,
"user_code": "",
"verification_uri": "https://windsurf.com/windsurf/signin",
"verification_uri_complete": authorization.authorize_url,
"expires_in": WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS,
"interval": WINDSURF_BROWSER_AUTH_POLL_INTERVAL_SECS,
"auth_type": "browser",
"login_option": login_option,
"redirect_uri": "show-auth-token",
"callback_required": true,
}))
.into_response());
}
let auth_type = normalize_kiro_device_auth_type(payload.auth_type.as_deref());
if let Some(social_provider) = kiro_social_provider_id(&auth_type) {
let redirect_uri = match normalize_kiro_social_redirect_uri(payload.redirect_uri.as_deref())
@@ -394,3 +514,27 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
}))
.into_response())
}
#[cfg(test)]
mod tests {
use super::build_windsurf_authorization_url;
#[test]
fn windsurf_authorization_url_includes_login_option() {
let url = build_windsurf_authorization_url(
"https://windsurf.com/windsurf/signin?state=session-1",
"github",
);
let parsed = url::Url::parse(&url).expect("url should parse");
let params = parsed
.query_pairs()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(params.get("state").map(String::as_str), Some("session-1"));
assert_eq!(
params.get("login_option").map(String::as_str),
Some("github")
);
}
}

View File

@@ -26,6 +26,9 @@ use aether_data::repository::provider_oauth::StoredAdminProviderOAuthDeviceSessi
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
};
use aether_oauth::provider::{
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
};
use axum::{
body::{Body, Bytes},
http,
@@ -86,6 +89,99 @@ fn kiro_social_poll_error_response(error: impl Into<String>) -> Response<Body> {
.into_response()
}
fn windsurf_browser_poll_error_response(error: impl Into<String>) -> Response<Body> {
Json(json!({
"status": "error",
"error": error.into(),
"replaced": false,
}))
.into_response()
}
fn sanitize_windsurf_browser_poll_detail(detail: impl AsRef<str>) -> String {
let detail = detail.as_ref().trim();
if detail.is_empty() {
return "-".to_string();
}
if contains_windsurf_sensitive_marker(detail) {
"[REDACTED upstream error body]".to_string()
} else {
detail.chars().take(500).collect()
}
}
fn sanitize_windsurf_browser_poll_callback_error(error: &str, description: &str) -> String {
let error = sanitize_windsurf_browser_poll_error_code(error);
let description = sanitize_windsurf_browser_poll_detail(description);
format!("{error}: {description}")
}
fn sanitize_windsurf_browser_poll_error_code(error: &str) -> String {
let error = error.trim();
if !error.is_empty()
&& error.len() <= 80
&& error
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return error.to_string();
}
sanitize_windsurf_browser_poll_detail(error)
}
fn sanitize_windsurf_browser_poll_oauth_error(error: &aether_oauth::core::OAuthError) -> String {
match error {
aether_oauth::core::OAuthError::InvalidRequest(_) => {
"Windsurf token 验证失败: 请求参数无效".to_string()
}
aether_oauth::core::OAuthError::HttpStatus { status_code, .. } => {
format!("Windsurf token 验证失败: HTTP {status_code}")
}
_ => "Windsurf token 验证失败".to_string(),
}
}
fn contains_windsurf_sensitive_marker(value: &str) -> bool {
let lowered = value.to_ascii_lowercase();
[
"token",
"api_key",
"apikey",
"sessiontoken",
"firebase_id_token",
"idtoken",
"authorization",
"password",
"secret",
"devin-session-token$",
]
.iter()
.any(|marker| lowered.contains(marker))
|| value.contains("sk-")
}
fn secret_fingerprint(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
use sha2::{Digest, Sha256};
let digest = Sha256::digest(value.as_bytes());
Some(
digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
)
}
fn insert_secret_fingerprint(target: &mut serde_json::Map<String, Value>, key: &str, secret: &str) {
if let Some(fingerprint) = secret_fingerprint(secret) {
target.insert(key.to_string(), json!(fingerprint));
}
}
fn kiro_social_provider_from_login_option(login_option: Option<&str>) -> Option<&'static str> {
match login_option
.map(str::trim)
@@ -322,8 +418,9 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
"Provider 不存在",
));
};
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
let endpoint_resolution =
resolve_provider_oauth_runtime_endpoints(state, &provider, "kiro").await?;
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
let endpoints = endpoint_resolution.endpoints;
let runtime_endpoint = endpoint_resolution.runtime_endpoint;
let request_proxy = state
@@ -338,6 +435,20 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
)
.await;
if provider_type == "windsurf" {
return handle_admin_provider_oauth_windsurf_browser_device_poll(
state,
&provider,
&endpoints,
request_proxy,
session_id,
session,
payload.callback_url.as_deref(),
payload.token.as_deref(),
)
.await;
}
if kiro_device_session_is_social(&session) {
return handle_admin_provider_oauth_kiro_social_device_poll(
state,
@@ -630,6 +741,276 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
))
}
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
}
}
async fn handle_admin_provider_oauth_windsurf_browser_device_poll(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
endpoints: &[StoredProviderCatalogEndpoint],
request_proxy: Option<ProxySnapshot>,
session_id: &str,
mut session: StoredAdminProviderOAuthDeviceSession,
callback_url: Option<&str>,
token: Option<&str>,
) -> Result<Response<Body>, GatewayError> {
let callback_url = callback_url
.map(str::trim)
.filter(|value| !value.is_empty());
let token = token.map(str::trim).filter(|value| !value.is_empty());
if callback_url.is_none() && token.is_none() {
return Ok(Json(json!({"status": "pending", "replaced": false})).into_response());
}
let mut social_provider = session
.social_provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let imported_token = if let Some(callback_url) = callback_url {
let callback_params = parse_provider_oauth_callback_params(callback_url);
if let Some(error) = callback_params.get("error").map(String::as_str) {
let error_description = callback_params
.get("error_description")
.map(String::as_str)
.unwrap_or("用户拒绝授权");
let sanitized_error =
sanitize_windsurf_browser_poll_callback_error(error, error_description);
session.status = "error".to_string();
session.error_msg = Some(sanitized_error.clone());
let _ = state
.save_provider_oauth_device_session(session_id, &session, 30)
.await;
return Ok(attach_admin_provider_oauth_device_poll_terminal_response(
session_id,
"error",
windsurf_browser_poll_error_response(sanitized_error),
));
}
let Some(callback_state) = callback_params
.get("state")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(windsurf_browser_poll_error_response("回调 URL 缺少 state"));
};
if callback_state != session_id {
return Ok(windsurf_browser_poll_error_response(
"回调 state 与会话不匹配",
));
}
if let Some(provider) = callback_params
.get("provider")
.or_else(|| callback_params.get("login_option"))
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
social_provider = Some(provider.to_string());
}
let Some(callback_token) = callback_params
.get("token")
.or_else(|| callback_params.get("auth_token"))
.or_else(|| callback_params.get("access_token"))
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(windsurf_browser_poll_error_response("回调 URL 缺少 token"));
};
callback_token.to_string()
} else {
let token = token.unwrap_or_default();
if windsurf_raw_api_key(token).is_none() {
return Ok(windsurf_browser_poll_error_response(
"浏览器授权请提交包含 state 的回调 URL纯 token 请使用导入授权",
));
}
token.to_string()
};
let mut raw_credentials = serde_json::Map::new();
if windsurf_raw_api_key(&imported_token).is_some() {
raw_credentials.insert("api_key".to_string(), json!(imported_token));
} else {
raw_credentials.insert("token".to_string(), json!(imported_token));
}
if let Some(social_provider) = social_provider.as_ref() {
raw_credentials.insert("social_provider".to_string(), json!(social_provider));
}
let ctx = ProviderOAuthTransportContext {
provider_id: provider.id.clone(),
provider_type: provider.provider_type.clone(),
endpoint_id: None,
key_id: None,
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: provider.config.clone(),
endpoint_config: None,
key_config: None,
network: aether_oauth::network::OAuthNetworkContext::provider_operation(
request_proxy.clone(),
),
};
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
let result = match ProviderOAuthService::with_builtin_adapters()
.import_credentials(
&executor,
&ctx,
ProviderOAuthImportInput {
provider_type: provider.provider_type.clone(),
name: None,
refresh_token: None,
raw_credentials: Some(Value::Object(raw_credentials)),
network: ctx.network.clone(),
},
)
.await
{
Ok(result) => result,
Err(error) => {
let sanitized_error = sanitize_windsurf_browser_poll_oauth_error(&error);
session.status = "error".to_string();
session.error_msg = Some(sanitized_error.clone());
let _ = state
.save_provider_oauth_device_session(session_id, &session, 30)
.await;
return Ok(attach_admin_provider_oauth_device_poll_terminal_response(
session_id,
"error",
windsurf_browser_poll_error_response(sanitized_error),
));
}
};
let access_token = result.token_set.access_token.trim().to_string();
if access_token.is_empty() {
return Ok(windsurf_browser_poll_error_response(
"Windsurf token 验证返回缺少 apiKey/sessionToken",
));
}
let mut auth_config = result.auth_config.as_object().cloned().unwrap_or_default();
auth_config.insert("provider_type".to_string(), json!("windsurf"));
auth_config.insert("auth_method".to_string(), json!("browser"));
if let Some(social_provider) = social_provider.as_ref() {
auth_config
.entry("social_provider".to_string())
.or_insert_with(|| json!(social_provider));
}
let duplicate = match state
.find_duplicate_provider_oauth_key(&provider.id, &auth_config, None)
.await
{
Ok(duplicate) => duplicate,
Err(detail) => {
return Ok(Json(json!({
"status": "error",
"error": detail,
"replaced": false,
}))
.into_response());
}
};
let api_formats = provider_oauth_active_api_formats(endpoints);
let key_proxy = provider_oauth_key_proxy_value(session.proxy_node_id.as_deref());
let expires_at = result.token_set.expires_at_unix_secs;
let email = auth_config
.get("email")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let mut replaced = false;
let persisted_key = if let Some(existing_key) = duplicate {
replaced = true;
match state
.update_existing_provider_oauth_catalog_key(
&existing_key,
&provider.provider_type,
&access_token,
&auth_config,
&api_formats,
key_proxy.clone(),
expires_at,
)
.await?
{
Some(key) => key,
None => {
return Ok(build_internal_control_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"provider oauth write unavailable",
));
}
}
} else {
let key_name = email
.as_deref()
.map(|email| format!("windsurf_{email}"))
.unwrap_or_else(|| format!("windsurf_{}", current_unix_secs()));
match state
.create_provider_oauth_catalog_key(
&provider.id,
&provider.provider_type,
&key_name,
&access_token,
&auth_config,
&api_formats,
key_proxy,
expires_at,
)
.await?
{
Some(key) => key,
None => {
return Ok(build_internal_control_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"provider oauth write unavailable",
));
}
}
};
spawn_provider_oauth_account_state_refresh_after_update(
state.cloned_app(),
provider.clone(),
persisted_key.id.clone(),
request_proxy.clone(),
);
session.status = "authorized".to_string();
session.key_id = Some(persisted_key.id.clone());
session.email = email.clone();
session.replaced = replaced;
session.error_msg = None;
let _ = state
.save_provider_oauth_device_session(session_id, &session, 60)
.await;
Ok(attach_admin_provider_oauth_device_poll_terminal_response(
session_id,
"authorized",
Json(json!({
"status": "authorized",
"key_id": persisted_key.id,
"email": email,
"replaced": replaced,
}))
.into_response(),
))
}
async fn handle_admin_provider_oauth_kiro_social_device_poll(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
@@ -814,7 +1195,7 @@ async fn handle_admin_provider_oauth_kiro_social_device_poll(
.get("idToken")
.or_else(|| token_result.get("id_token")),
) {
auth_config_object.insert("id_token".to_string(), json!(id_token));
insert_secret_fingerprint(&mut auth_config_object, "id_token_fingerprint", &id_token);
}
if let Some(token_type) = json_non_empty_string(
token_result
@@ -921,3 +1302,18 @@ async fn handle_admin_provider_oauth_kiro_social_device_poll(
.into_response(),
))
}
#[cfg(test)]
mod tests {
#[test]
fn windsurf_browser_poll_callback_error_redacts_sensitive_values() {
let detail = super::sanitize_windsurf_browser_poll_callback_error(
"access_denied",
"bad token devin-session-token$secret and apiKey sk-secret",
);
assert_eq!(detail, "access_denied: [REDACTED upstream error body]");
assert!(!detail.contains("devin-session-token$secret"));
assert!(!detail.contains("sk-secret"));
}
}

View File

@@ -12,6 +12,7 @@ pub(super) struct AdminProviderOAuthDeviceAuthorizePayload {
#[serde(default = "default_kiro_device_region")]
pub(super) region: String,
pub(super) auth_type: Option<String>,
pub(super) login_option: Option<String>,
pub(super) redirect_uri: Option<String>,
pub(super) proxy_node_id: Option<String>,
}
@@ -20,6 +21,7 @@ pub(super) struct AdminProviderOAuthDeviceAuthorizePayload {
pub(super) struct AdminProviderOAuthDevicePollPayload {
pub(super) session_id: String,
pub(super) callback_url: Option<String>,
pub(super) token: Option<String>,
}
pub(super) fn attach_admin_provider_oauth_device_poll_terminal_response(

View File

@@ -25,6 +25,10 @@ use crate::handlers::admin::request::{
};
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
use aether_oauth::core::OAuthError;
use aether_oauth::provider::{
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
};
use axum::{
body::Body,
http,
@@ -39,6 +43,27 @@ struct AdminProviderOAuthSingleImportTokens {
expires_at: Option<u64>,
}
fn sanitize_windsurf_import_error(error: &OAuthError) -> String {
match error {
OAuthError::InvalidRequest(_) => "Windsurf 凭据验证失败: 请求参数无效".to_string(),
OAuthError::HttpStatus { status_code, .. } => {
format!("Windsurf 凭据验证失败: HTTP {status_code}")
}
_ => "Windsurf 凭据验证失败".to_string(),
}
}
fn import_payload_has_windsurf_credentials(
payload: &serde_json::Map<String, serde_json::Value>,
) -> bool {
import_payload_string(payload, "api_key", "apiKey").is_some()
|| import_payload_string_any(payload, &["token", "auth_token", "authToken"]).is_some()
|| import_payload_string(payload, "refresh_token", "refreshToken").is_some()
|| import_payload_string(payload, "access_token", "accessToken").is_some()
|| (import_payload_string_any(payload, &["email"]).is_some()
&& import_payload_string_any(payload, &["password"]).is_some())
}
fn import_payload_string(
payload: &serde_json::Map<String, serde_json::Value>,
snake_case: &str,
@@ -266,6 +291,70 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
})
}
async fn resolve_admin_provider_oauth_windsurf_single_import_tokens(
state: &AdminAppState<'_>,
provider_type: &str,
name: Option<String>,
raw_payload: &serde_json::Map<String, serde_json::Value>,
refresh_token: Option<&str>,
request_proxy: Option<ProxySnapshot>,
) -> Result<AdminProviderOAuthSingleImportTokens, Response<Body>> {
let ctx = ProviderOAuthTransportContext {
provider_id: String::new(),
provider_type: provider_type.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: aether_oauth::network::OAuthNetworkContext::provider_operation(
request_proxy.clone(),
),
};
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
let service = ProviderOAuthService::with_builtin_adapters();
let result = service
.import_credentials(
&executor,
&ctx,
ProviderOAuthImportInput {
provider_type: provider_type.to_string(),
name,
refresh_token: refresh_token.map(ToOwned::to_owned),
raw_credentials: Some(serde_json::Value::Object(raw_payload.clone())),
network: ctx.network.clone(),
},
)
.await
.map_err(|error| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
sanitize_windsurf_import_error(&error),
)
})?;
let access_token = result.token_set.access_token.trim().to_string();
if access_token.is_empty() {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Windsurf 凭据验证返回缺少 apiKey/sessionToken",
));
}
let auth_config = result.auth_config.as_object().cloned().ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Windsurf 凭据验证返回缺少 auth_config",
)
})?;
Ok(AdminProviderOAuthSingleImportTokens {
access_token,
auth_config,
expires_at: result.token_set.expires_at_unix_secs,
})
}
pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -370,19 +459,50 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
.await;
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id.as_deref());
let resolved_import = match resolve_admin_provider_oauth_single_import_tokens(
state,
template,
&provider_type,
refresh_token_input.as_deref(),
access_token_input.as_deref(),
imported_expires_at,
request_proxy.clone(),
)
.await
{
Ok(value) => value,
Err(response) => return Ok(response),
let resolved_import = if provider_type == "windsurf" {
if !import_payload_has_windsurf_credentials(&raw_payload) {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Windsurf 凭据不能为空",
));
}
match resolve_admin_provider_oauth_windsurf_single_import_tokens(
state,
&provider_type,
name.clone(),
&raw_payload,
refresh_token_input.as_deref(),
request_proxy.clone(),
)
.await
{
Ok(value) => value,
Err(response) => return Ok(response),
}
} else {
if refresh_token_input.is_none() && access_token_input.is_none() {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Refresh Token 或 Access Token 不能为空",
));
}
let Some(template) = admin_provider_oauth_template(&provider_type) else {
return Ok(build_admin_provider_oauth_backend_unavailable_response());
};
match resolve_admin_provider_oauth_single_import_tokens(
state,
template,
&provider_type,
refresh_token_input.as_deref(),
access_token_input.as_deref(),
imported_expires_at,
request_proxy.clone(),
)
.await
{
Ok(value) => value,
Err(response) => return Ok(response),
}
};
let AdminProviderOAuthSingleImportTokens {
access_token,
@@ -480,3 +600,35 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
}))
.into_response())
}
#[cfg(test)]
mod tests {
use super::sanitize_windsurf_import_error;
use aether_oauth::core::OAuthError;
#[test]
fn windsurf_import_error_redacts_http_body() {
let error = OAuthError::HttpStatus {
status_code: 400,
body_excerpt: "token=secret-token password=secret-password".to_string(),
};
let detail = sanitize_windsurf_import_error(&error);
assert_eq!(detail, "Windsurf 凭据验证失败: HTTP 400");
assert!(!detail.contains("secret-token"));
assert!(!detail.contains("secret-password"));
}
#[test]
fn windsurf_import_error_redacts_invalid_response_detail() {
let error =
OAuthError::invalid_response("RegisterUser failed with firebase_id_token=secret-token");
let detail = sanitize_windsurf_import_error(&error);
assert_eq!(detail, "Windsurf 凭据验证失败");
assert!(!detail.contains("secret-token"));
assert!(!detail.contains("firebase_id_token"));
}
}

View File

@@ -63,6 +63,12 @@ pub(super) async fn handle_admin_provider_oauth_start_key(
"该 Provider 不是固定类型,无法使用 provider-oauth",
));
}
if provider_type == "windsurf" {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Windsurf 请使用浏览器登录或导入凭据。",
));
}
let Some(template) = admin_provider_oauth_template(&provider_type) else {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
@@ -134,6 +140,12 @@ pub(super) async fn handle_admin_provider_oauth_start_provider(
"Kiro 不支持 OAuth 授权,请使用导入授权。",
));
}
if provider_type == "windsurf" {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Windsurf 请使用浏览器登录或导入凭据。",
));
}
let Some(template) = admin_provider_oauth_template(&provider_type) else {
return Ok(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,

View File

@@ -36,6 +36,13 @@ fn is_openai_provider_oauth_provider_type(value: Option<&serde_json::Value>) ->
})
}
fn is_windsurf_provider_oauth_provider_type(value: Option<&serde_json::Value>) -> bool {
value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.is_some_and(|provider_type| provider_type.eq_ignore_ascii_case("windsurf"))
}
fn match_codex_provider_oauth_identity(
new_auth_config: &serde_json::Map<String, serde_json::Value>,
existing_auth_config: &serde_json::Map<String, serde_json::Value>,
@@ -106,6 +113,41 @@ fn match_codex_provider_oauth_identity(
None
}
fn match_windsurf_provider_oauth_identity(
new_auth_config: &serde_json::Map<String, serde_json::Value>,
existing_auth_config: &serde_json::Map<String, serde_json::Value>,
) -> Option<bool> {
let new_provider_type = new_auth_config.get("provider_type");
let existing_provider_type = existing_auth_config.get("provider_type");
if !is_windsurf_provider_oauth_provider_type(new_provider_type)
&& !is_windsurf_provider_oauth_provider_type(existing_provider_type)
{
return None;
}
let new_account_id = normalize_provider_oauth_identity_value(new_auth_config.get("account_id"));
let existing_account_id =
normalize_provider_oauth_identity_value(existing_auth_config.get("account_id"));
if let (Some(new_account_id), Some(existing_account_id)) =
(new_account_id.as_deref(), existing_account_id.as_deref())
{
return Some(new_account_id == existing_account_id);
}
let new_credential_fingerprint =
normalize_provider_oauth_identity_value(new_auth_config.get("credential_fingerprint"));
let existing_credential_fingerprint =
normalize_provider_oauth_identity_value(existing_auth_config.get("credential_fingerprint"));
if let (Some(new_fingerprint), Some(existing_fingerprint)) = (
new_credential_fingerprint.as_deref(),
existing_credential_fingerprint.as_deref(),
) {
return Some(new_fingerprint == existing_fingerprint);
}
None
}
fn is_codex_cross_plan_group_non_duplicate(
new_auth_config: &serde_json::Map<String, serde_json::Value>,
existing_auth_config: &serde_json::Map<String, serde_json::Value>,
@@ -169,10 +211,17 @@ pub(crate) async fn find_duplicate_provider_oauth_key(
) -> Result<Option<StoredProviderCatalogKey>, String> {
let new_email = normalize_provider_oauth_identity_value(auth_config.get("email"));
let new_user_id = normalize_provider_oauth_identity_value(auth_config.get("user_id"));
let new_account_id = normalize_provider_oauth_identity_value(auth_config.get("account_id"));
let new_credential_fingerprint =
normalize_provider_oauth_identity_value(auth_config.get("credential_fingerprint"));
let new_auth_method = normalize_provider_oauth_identity_value(auth_config.get("auth_method"));
let new_kiro_provider = normalize_provider_oauth_identity_value(auth_config.get("provider"));
if new_email.is_none() && new_user_id.is_none() {
if new_email.is_none()
&& new_user_id.is_none()
&& new_account_id.is_none()
&& new_credential_fingerprint.is_none()
{
return Ok(None);
}
@@ -201,15 +250,28 @@ pub(crate) async fn find_duplicate_provider_oauth_key(
normalize_provider_oauth_identity_value(existing_auth_config.get("auth_method"));
let existing_kiro_provider =
normalize_provider_oauth_identity_value(existing_auth_config.get("provider"));
let is_windsurf = auth_config
.get("provider_type")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("windsurf"))
|| existing_auth_config
.get("provider_type")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("windsurf"));
let mut is_duplicate = false;
let codex_identity_match =
match_codex_provider_oauth_identity(auth_config, &existing_auth_config);
let windsurf_identity_match =
match_windsurf_provider_oauth_identity(auth_config, &existing_auth_config);
if let Some(codex_identity_match) = codex_identity_match {
is_duplicate = codex_identity_match;
} else if let Some(windsurf_identity_match) = windsurf_identity_match {
is_duplicate = windsurf_identity_match;
}
if codex_identity_match.is_none()
&& windsurf_identity_match.is_none()
&& !is_duplicate
&& new_user_id.is_some()
&& existing_user_id.is_some()
@@ -220,7 +282,9 @@ pub(crate) async fn find_duplicate_provider_oauth_key(
}
if codex_identity_match.is_none()
&& windsurf_identity_match.is_none()
&& !is_duplicate
&& !is_windsurf
&& new_email.is_some()
&& existing_email.is_some()
&& new_email == existing_email
@@ -261,6 +325,12 @@ pub(crate) async fn find_duplicate_provider_oauth_key(
let identifier =
normalize_provider_oauth_identity_value(auth_config.get("account_user_id"))
.or_else(|| normalize_provider_oauth_identity_value(auth_config.get("account_id")))
.or_else(|| {
normalize_provider_oauth_identity_value(
auth_config.get("credential_fingerprint"),
)
.map(|value| format!("fingerprint:{value}"))
})
.or_else(|| new_email.clone())
.or_else(|| new_user_id.clone())
.unwrap_or_default();
@@ -272,3 +342,91 @@ pub(crate) async fn find_duplicate_provider_oauth_key(
Ok(None)
}
#[cfg(test)]
mod tests {
use super::match_windsurf_provider_oauth_identity;
use serde_json::{json, Map, Value};
fn auth_config(value: Value) -> Map<String, Value> {
value.as_object().cloned().expect("auth config object")
}
#[test]
fn windsurf_identity_matches_account_id_without_email() {
let new_auth_config = auth_config(json!({
"provider_type": "windsurf",
"auth_method": "api_key",
"account_id": "acct-ws-1"
}));
let existing_auth_config = auth_config(json!({
"provider_type": "windsurf",
"auth_method": "browser",
"account_id": "acct-ws-1"
}));
assert_eq!(
match_windsurf_provider_oauth_identity(&new_auth_config, &existing_auth_config),
Some(true)
);
}
#[test]
fn windsurf_identity_rejects_different_account_id() {
let new_auth_config = auth_config(json!({
"provider_type": "windsurf",
"account_id": "acct-ws-1",
"email": "same@example.com"
}));
let existing_auth_config = auth_config(json!({
"provider_type": "windsurf",
"account_id": "acct-ws-2",
"email": "same@example.com"
}));
assert_eq!(
match_windsurf_provider_oauth_identity(&new_auth_config, &existing_auth_config),
Some(false)
);
}
#[test]
fn windsurf_identity_matches_credential_fingerprint_without_profile() {
let new_auth_config = auth_config(json!({
"provider_type": "windsurf",
"auth_method": "api_key",
"credential_fingerprint": "abcdef0123456789"
}));
let existing_auth_config = auth_config(json!({
"provider_type": "windsurf",
"auth_method": "browser",
"credential_fingerprint": "abcdef0123456789"
}));
assert_eq!(
match_windsurf_provider_oauth_identity(&new_auth_config, &existing_auth_config),
Some(true)
);
}
#[test]
fn windsurf_identity_does_not_match_user_supplied_email_only() {
let new_auth_config = auth_config(json!({
"provider_type": "windsurf",
"auth_method": "api_key",
"email": "same@example.com",
"email_verified": false
}));
let existing_auth_config = auth_config(json!({
"provider_type": "windsurf",
"auth_method": "api_key",
"email": "same@example.com",
"email_verified": false
}));
assert_eq!(
match_windsurf_provider_oauth_identity(&new_auth_config, &existing_auth_config),
None
);
}
}

View File

@@ -6,6 +6,7 @@ use super::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
use super::codex::refresh_codex_provider_quota_locally;
use super::grok::refresh_grok_provider_quota_locally;
use super::kiro::refresh_kiro_provider_quota_locally;
use super::windsurf::refresh_windsurf_provider_quota_locally;
use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
@@ -36,6 +37,7 @@ const PROVIDER_QUOTA_REFRESH_HANDLERS: &[(&str, ProviderQuotaRefreshHandler)] =
("codex", refresh_codex_provider_quota_locally_boxed),
("grok", refresh_grok_provider_quota_locally_boxed),
("kiro", refresh_kiro_provider_quota_locally_boxed),
("windsurf", refresh_windsurf_provider_quota_locally_boxed),
];
pub(crate) async fn refresh_provider_pool_quota_locally(
@@ -135,3 +137,19 @@ fn refresh_grok_provider_quota_locally_boxed<'a>(
proxy_override,
))
}
fn refresh_windsurf_provider_quota_locally_boxed<'a>(
state: &'a AdminAppState<'a>,
provider: &'a StoredProviderCatalogProvider,
endpoint: &'a StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> ProviderQuotaRefreshFuture<'a> {
Box::pin(refresh_windsurf_provider_quota_locally(
state,
provider,
endpoint,
keys,
proxy_override,
))
}

View File

@@ -5,3 +5,4 @@ pub(crate) mod dispatch;
pub(crate) mod grok;
pub(crate) mod kiro;
pub(crate) mod shared;
pub(crate) mod windsurf;

View File

@@ -0,0 +1,632 @@
use super::shared::{
build_provider_quota_execution_plan, build_quota_snapshot_payload,
default_provider_quota_execution_timeouts, execute_provider_quota_plan,
extract_execution_error_message, persist_provider_quota_refresh_state,
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_provider_pool::{
build_windsurf_pool_model_configs_request_with_base_url,
build_windsurf_pool_quota_request_with_base_url,
build_windsurf_pool_rate_limit_request_with_base_url, ProviderPoolQuotaRequestSpec,
};
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
async fn execute_windsurf_probe_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
spec: ProviderPoolQuotaRequestSpec,
proxy_override: Option<&ProxySnapshot>,
quota_kind: &str,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let proxy = match proxy_override {
Some(proxy) => Some(proxy.clone()),
None => {
state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await
}
};
let timeouts = state
.resolve_transport_execution_timeouts(transport)
.or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(),
)));
let plan = build_provider_quota_execution_plan(
transport,
spec,
proxy,
state.resolve_transport_profile(transport),
timeouts,
);
execute_provider_quota_plan(state, transport, plan, quota_kind).await
}
async fn execute_windsurf_user_status_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
api_key: &str,
proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let spec = build_windsurf_pool_quota_request_with_base_url(
&transport.key.id,
&transport.endpoint.base_url,
api_key,
);
execute_windsurf_probe_plan(
state,
transport,
spec,
proxy_override,
"windsurf:user_status",
)
.await
}
async fn execute_windsurf_model_configs_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
api_key: &str,
proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let spec = build_windsurf_pool_model_configs_request_with_base_url(
&transport.key.id,
&transport.endpoint.base_url,
api_key,
);
execute_windsurf_probe_plan(
state,
transport,
spec,
proxy_override,
"windsurf:model_configs",
)
.await
}
async fn execute_windsurf_rate_limit_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
api_key: &str,
proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let spec = build_windsurf_pool_rate_limit_request_with_base_url(
&transport.key.id,
&transport.endpoint.base_url,
api_key,
);
execute_windsurf_probe_plan(
state,
transport,
spec,
proxy_override,
"windsurf:rate_limit",
)
.await
}
fn merge_windsurf_probe_metadata(
mut user_status_metadata: serde_json::Value,
model_configs_metadata: Option<serde_json::Value>,
rate_limit_metadata: Option<serde_json::Value>,
) -> serde_json::Value {
let Some(target) = user_status_metadata.as_object_mut() else {
return user_status_metadata;
};
for metadata in [model_configs_metadata, rate_limit_metadata]
.into_iter()
.flatten()
{
if let Some(source) = metadata.as_object() {
for (key, value) in source {
target.insert(key.clone(), value.clone());
}
}
}
user_status_metadata
}
fn append_windsurf_probe_warning(metadata: &mut serde_json::Value, probe: &str, message: String) {
let Some(target) = metadata.as_object_mut() else {
return;
};
let warnings = target
.entry("probe_warnings".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let Some(items) = warnings.as_array_mut() {
items.push(json!({
"probe": probe,
"message": message,
}));
}
}
fn build_windsurf_metadata_update(
current_upstream_metadata: Option<&serde_json::Value>,
patch: serde_json::Value,
) -> serde_json::Value {
let Some(patch_object) = patch.as_object() else {
return json!({ "windsurf": patch });
};
let mut merged_bucket = current_upstream_metadata
.and_then(|value| value.get("windsurf"))
.and_then(serde_json::Value::as_object)
.cloned()
.unwrap_or_default();
for (key, value) in patch_object {
merged_bucket.insert(key.clone(), value.clone());
}
json!({ "windsurf": merged_bucket })
}
fn sanitize_windsurf_probe_detail(detail: impl AsRef<str>) -> String {
let detail = detail.as_ref().trim();
if detail.is_empty() {
return "-".to_string();
}
if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(detail) {
redact_windsurf_sensitive_json(&mut value);
return value.to_string().chars().take(500).collect();
}
if contains_windsurf_sensitive_marker(detail) {
"[REDACTED upstream error body]".to_string()
} else {
detail.chars().take(500).collect()
}
}
fn redact_windsurf_sensitive_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
if is_windsurf_sensitive_key(key) {
*value = json!("[REDACTED]");
} else {
redact_windsurf_sensitive_json(value);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
redact_windsurf_sensitive_json(item);
}
}
serde_json::Value::String(text) if looks_like_windsurf_secret(text) => {
*text = "[REDACTED]".to_string();
}
_ => {}
}
}
fn is_windsurf_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_windsurf_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_windsurf_sensitive_marker(value: &str) -> bool {
let lowered = value.to_ascii_lowercase();
[
"token",
"api_key",
"apikey",
"sessiontoken",
"firebase_id_token",
"idtoken",
"authorization",
"password",
"secret",
"devin-session-token$",
]
.iter()
.any(|marker| lowered.contains(marker))
|| value.contains("sk-")
}
pub(crate) async fn refresh_windsurf_provider_quota_locally(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
endpoint: &StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> Result<Option<serde_json::Value>, GatewayError> {
let mut results = Vec::new();
let mut success_count = 0usize;
let mut failed_count = 0usize;
for key in keys {
let transport = match state
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
.await?
{
Some(transport) => transport,
None => {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "Provider transport snapshot unavailable",
}));
continue;
}
};
let api_key = transport.key.decrypted_api_key.trim();
if api_key.is_empty() {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "缺少 Windsurf apiKey/sessionToken",
}));
continue;
}
let result = match execute_windsurf_user_status_plan(
state,
&transport,
api_key,
proxy_override.as_ref(),
)
.await?
{
ProviderQuotaExecutionOutcome::Response(result) => result,
ProviderQuotaExecutionOutcome::Failure(detail) => {
failed_count += 1;
let detail = sanitize_windsurf_probe_detail(detail);
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": format!("GetUserStatus 请求执行失败: {detail}"),
"status_code": 502,
}));
continue;
}
};
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let mut metadata_update = None::<serde_json::Value>;
let (mut oauth_invalid_at_unix_secs, mut oauth_invalid_reason) =
quota_refresh_success_invalid_state(&key);
let mut status = "error".to_string();
let mut message = None::<String>;
if result.status_code == 200 {
if let Some(body_json) = result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
{
let mut windsurf_metadata =
aether_admin::provider::quota::parse_windsurf_user_status_response(
body_json,
now_unix_secs,
);
if let Some(mut metadata) = windsurf_metadata.take() {
let model_metadata = match execute_windsurf_model_configs_plan(
state,
&transport,
api_key,
proxy_override.as_ref(),
)
.await?
{
ProviderQuotaExecutionOutcome::Response(model_result)
if model_result.status_code == 200 =>
{
model_result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
.and_then(|body_json| {
aether_admin::provider::quota::parse_windsurf_model_configs_response(
body_json,
now_unix_secs,
)
})
}
ProviderQuotaExecutionOutcome::Response(model_result) => {
let detail = extract_execution_error_message(&model_result)
.unwrap_or_else(|| format!("HTTP {}", model_result.status_code));
let detail = sanitize_windsurf_probe_detail(detail);
append_windsurf_probe_warning(
&mut metadata,
"model_configs",
format!("GetCascadeModelConfigs 返回: {detail}"),
);
None
}
ProviderQuotaExecutionOutcome::Failure(detail) => {
let detail = sanitize_windsurf_probe_detail(detail);
append_windsurf_probe_warning(
&mut metadata,
"model_configs",
format!("GetCascadeModelConfigs 执行失败: {detail}"),
);
None
}
};
let rate_limit_metadata = match execute_windsurf_rate_limit_plan(
state,
&transport,
api_key,
proxy_override.as_ref(),
)
.await?
{
ProviderQuotaExecutionOutcome::Response(rate_limit_result)
if rate_limit_result.status_code == 200 =>
{
rate_limit_result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
.and_then(|body_json| {
aether_admin::provider::quota::parse_windsurf_rate_limit_response(
body_json,
now_unix_secs,
)
})
}
ProviderQuotaExecutionOutcome::Response(rate_limit_result) => {
let detail = extract_execution_error_message(&rate_limit_result)
.unwrap_or_else(|| format!("HTTP {}", rate_limit_result.status_code));
let detail = sanitize_windsurf_probe_detail(detail);
append_windsurf_probe_warning(
&mut metadata,
"rate_limit",
format!("CheckUserMessageRateLimit 返回: {detail}"),
);
None
}
ProviderQuotaExecutionOutcome::Failure(detail) => {
let detail = sanitize_windsurf_probe_detail(detail);
append_windsurf_probe_warning(
&mut metadata,
"rate_limit",
format!("CheckUserMessageRateLimit 执行失败: {detail}"),
);
None
}
};
metadata = merge_windsurf_probe_metadata(
metadata,
model_metadata,
rate_limit_metadata,
);
metadata_update = Some(build_windsurf_metadata_update(
key.upstream_metadata.as_ref(),
metadata,
));
status = "success".to_string();
} else {
status = "no_metadata".to_string();
message = Some("响应中未包含 Windsurf 限额信息".to_string());
}
} else {
status = "no_metadata".to_string();
message = Some("无法解析 GetUserStatus 响应".to_string());
}
} else {
let err_msg =
extract_execution_error_message(&result).map(sanitize_windsurf_probe_detail);
message = Some(match err_msg.as_deref() {
Some(detail) if !detail.is_empty() => {
format!(
"GetUserStatus 返回状态码 {}: {}",
result.status_code, detail
)
}
_ => format!("GetUserStatus 返回状态码 {}", result.status_code),
});
let detail = err_msg
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| format!("HTTP {}", result.status_code));
let mut metadata = serde_json::Map::new();
metadata.insert("updated_at".to_string(), json!(now_unix_secs));
metadata.insert("last_error".to_string(), json!(detail));
match result.status_code {
401 | 403 => {
oauth_invalid_at_unix_secs = Some(now_unix_secs);
oauth_invalid_reason =
Some(format!("Windsurf token 无效或已被拒绝: {}", detail));
metadata.insert("banned".to_string(), json!(result.status_code == 403));
status = if result.status_code == 401 {
"auth_invalid".to_string()
} else {
"forbidden".to_string()
};
}
429 => {
metadata.insert(
"rate_limit".to_string(),
json!({
"limited": true,
"message": metadata
.get("last_error")
.cloned()
.unwrap_or_else(|| json!("rate limited")),
}),
);
status = "rate_limited".to_string();
}
_ => {}
}
metadata_update = Some(build_windsurf_metadata_update(
key.upstream_metadata.as_ref(),
serde_json::Value::Object(metadata),
));
}
if !persist_provider_quota_refresh_state(
state,
&key.id,
metadata_update.as_ref(),
oauth_invalid_at_unix_secs,
oauth_invalid_reason,
None,
)
.await?
{
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "Key 状态写入失败",
}));
continue;
}
if status == "success" {
success_count += 1;
} else {
failed_count += 1;
}
let mut payload = serde_json::Map::new();
payload.insert("key_id".to_string(), json!(key.id));
payload.insert("key_name".to_string(), json!(key.name));
payload.insert("status".to_string(), json!(status));
if let Some(message) = message {
payload.insert("message".to_string(), json!(message));
}
if result.status_code != 200 {
payload.insert("status_code".to_string(), json!(result.status_code));
}
if let Some(metadata) = metadata_update
.as_ref()
.and_then(|value| value.get("windsurf"))
.cloned()
{
payload.insert("metadata".to_string(), metadata);
}
if let Some(quota_snapshot) = build_quota_snapshot_payload(
"windsurf",
key.status_snapshot.as_ref(),
metadata_update.as_ref(),
) {
payload.insert("quota_snapshot".to_string(), quota_snapshot);
}
results.push(serde_json::Value::Object(payload));
}
Ok(Some(json!({
"success": success_count,
"failed": failed_count,
"total": success_count + failed_count,
"results": results,
"message": format!("已处理 {} 个 Key", success_count + failed_count),
"auto_removed": 0,
})))
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn windsurf_probe_metadata_merges_user_status_models_and_rate_limit() {
let metadata = super::merge_windsurf_probe_metadata(
json!({
"plan_name": "Pro",
"daily_remaining_percent": 42.0,
"updated_at": 1_770_000_000u64,
}),
Some(json!({
"allowed_models_count": 2u64,
"models": [
{"model_uid": "claude-sonnet-4-5"},
{"model_uid": "gpt-5-mini"}
],
"updated_at": 1_770_000_010u64,
})),
Some(json!({
"rate_limit": {
"limited": true,
"messages_remaining": 0.0,
"retry_after_ms": 60_000u64
},
"updated_at": 1_770_000_020u64,
})),
);
assert_eq!(metadata["plan_name"], json!("Pro"));
assert_eq!(metadata["daily_remaining_percent"], json!(42.0));
assert_eq!(metadata["allowed_models_count"], json!(2u64));
assert_eq!(metadata["rate_limit"]["limited"], json!(true));
assert_eq!(metadata["updated_at"], json!(1_770_000_020u64));
}
#[test]
fn windsurf_probe_detail_redacts_sensitive_values() {
let detail = super::sanitize_windsurf_probe_detail(
r#"{"error":{"message":"bad"},"apiKey":"sk-secret","sessionToken":"devin-session-token$secret"}"#,
);
assert!(detail.contains("[REDACTED]"));
assert!(!detail.contains("sk-secret"));
assert!(!detail.contains("devin-session-token$secret"));
}
#[test]
fn windsurf_metadata_update_preserves_existing_bucket_fields() {
let update = super::build_windsurf_metadata_update(
Some(&json!({
"windsurf": {
"daily_remaining_percent": 0.0,
"allowed_models_count": 3,
"updated_at": 1u64
}
})),
json!({
"last_error": "HTTP 429",
"rate_limit": {"limited": true},
"updated_at": 2u64
}),
);
assert_eq!(
update.pointer("/windsurf/daily_remaining_percent"),
Some(&json!(0.0))
);
assert_eq!(
update.pointer("/windsurf/allowed_models_count"),
Some(&json!(3))
);
assert_eq!(update.pointer("/windsurf/updated_at"), Some(&json!(2u64)));
assert_eq!(
update.pointer("/windsurf/rate_limit/limited"),
Some(&json!(true))
);
}
}

View File

@@ -4,9 +4,9 @@ pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, Strin
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"custom" | "claude_code" | "kiro" | "codex" | "chatgpt_web" | "gemini_cli"
| "antigravity" | "vertex_ai" | "grok" => Ok(normalized),
| "antigravity" | "vertex_ai" | "grok" | "windsurf" => Ok(normalized),
_ => Err(
"provider_type 仅支持 custom / claude_code / kiro / codex / chatgpt_web / gemini_cli / antigravity / vertex_ai / grok"
"provider_type 仅支持 custom / claude_code / kiro / codex / chatgpt_web / gemini_cli / antigravity / vertex_ai / grok / windsurf"
.to_string(),
),
}

View File

@@ -1112,6 +1112,284 @@ fn build_chatgpt_web_quota_status_snapshot(
}))
}
fn windsurf_percent_quota_window_snapshot(
metadata: &Map<String, Value>,
code: &str,
label: &str,
remaining_percent_key: &str,
reset_at_key: &str,
observed_at_unix_secs: Option<u64>,
) -> Option<Value> {
let remaining_percent = metadata
.get(remaining_percent_key)
.and_then(admin_provider_quota_pure::coerce_json_f64);
let reset_at = provider_quota_timestamp_unix_secs(metadata.get(reset_at_key));
if remaining_percent.is_none() && reset_at.is_none() {
return None;
}
let remaining_ratio = remaining_percent.map(|value| (value / 100.0).clamp(0.0, 1.0));
let used_ratio = remaining_ratio.map(|value| (1.0 - value).clamp(0.0, 1.0));
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, reset_at);
Some(json!({
"code": code,
"label": label,
"scope": "account",
"unit": "percent",
"used_ratio": used_ratio,
"remaining_ratio": remaining_ratio,
"reset_at": reset_at,
"reset_seconds": reset_seconds,
"is_exhausted": remaining_ratio.map(|value| value <= 1e-6),
}))
}
fn windsurf_count_quota_window_snapshot(
metadata: &Map<String, Value>,
code: &str,
label: &str,
used_key: &str,
limit_key: &str,
remaining_key: &str,
) -> Option<Value> {
let used = metadata
.get(used_key)
.and_then(admin_provider_quota_pure::coerce_json_f64);
let limit = metadata
.get(limit_key)
.and_then(admin_provider_quota_pure::coerce_json_f64);
let remaining = metadata
.get(remaining_key)
.and_then(admin_provider_quota_pure::coerce_json_f64)
.or_else(|| limit.zip(used).map(|(limit, used)| (limit - used).max(0.0)));
if used.is_none() && limit.is_none() && remaining.is_none() {
return None;
}
let used_ratio = used
.zip(limit)
.and_then(|(used, limit)| (limit > 0.0).then_some((used / limit).clamp(0.0, 1.0)));
let remaining_ratio = remaining.zip(limit).and_then(|(remaining, limit)| {
(limit > 0.0).then_some((remaining / limit).clamp(0.0, 1.0))
});
Some(json!({
"code": code,
"label": label,
"scope": "account",
"unit": "count",
"used_ratio": used_ratio,
"remaining_ratio": remaining_ratio,
"used_value": used,
"remaining_value": remaining,
"limit_value": limit,
"is_exhausted": remaining.is_some_and(|value| value <= 0.0),
}))
}
fn build_windsurf_quota_status_snapshot(
upstream_metadata: Option<&Value>,
source: &str,
) -> Option<Value> {
let metadata = provider_quota_metadata_bucket(upstream_metadata, "windsurf")?;
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
let plan_type = metadata
.get("plan_name")
.or_else(|| metadata.get("plan_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let rate_limit = metadata
.get("rate_limit")
.cloned()
.filter(|value| !value.is_null());
let last_error = metadata
.get("last_error")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let banned = metadata
.get("banned")
.or_else(|| metadata.get("is_banned"))
.and_then(admin_provider_quota_pure::coerce_json_bool)
== Some(true);
let quarantined = metadata
.get("quarantined")
.or_else(|| metadata.get("is_quarantined"))
.and_then(admin_provider_quota_pure::coerce_json_bool)
== Some(true);
let mut windows = [
windsurf_percent_quota_window_snapshot(
metadata,
"daily",
"",
"daily_remaining_percent",
"daily_reset_at",
observed_at_unix_secs,
),
windsurf_percent_quota_window_snapshot(
metadata,
"weekly",
"",
"weekly_remaining_percent",
"weekly_reset_at",
observed_at_unix_secs,
),
windsurf_count_quota_window_snapshot(
metadata,
"prompt",
"Prompt",
"prompt_used",
"prompt_limit",
"prompt_remaining",
),
windsurf_count_quota_window_snapshot(
metadata,
"flex",
"Flex",
"flex_used",
"flex_limit",
"flex_remaining",
),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let mut rate_limit_cooling = false;
let mut rate_limit_reset_seconds = None;
let mut rate_limit_reason = None::<String>;
if let Some(rate_limit) = rate_limit.as_ref() {
if let Some(rate_limit_object) = rate_limit.as_object() {
let retry_after_ms = rate_limit_object
.get("retry_after_ms")
.or_else(|| rate_limit_object.get("retryAfterMs"))
.and_then(admin_provider_quota_pure::coerce_json_u64);
let limited = rate_limit_object
.get("limited")
.or_else(|| rate_limit_object.get("is_limited"))
.or_else(|| rate_limit_object.get("isLimited"))
.and_then(admin_provider_quota_pure::coerce_json_bool)
== Some(true);
if limited || retry_after_ms.is_some() {
rate_limit_cooling = true;
rate_limit_reset_seconds =
retry_after_ms.map(|value| value.saturating_add(999) / 1000);
rate_limit_reason = rate_limit_object
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
windows.push(json!({
"code": "rate_limit",
"label": "速率",
"scope": "account",
"unit": "count",
"is_exhausted": false,
"reset_seconds": rate_limit_reset_seconds,
}));
}
}
}
let allowed_models_count = metadata
.get("allowed_models_count")
.or_else(|| metadata.get("models_count"))
.and_then(admin_provider_quota_pure::coerce_json_u64);
if windows.is_empty()
&& plan_type.is_none()
&& observed_at_unix_secs.is_none()
&& rate_limit.is_none()
&& allowed_models_count.is_none()
&& !banned
&& !quarantined
{
return None;
}
let usage_ratio = quota_windows_usage_ratio(&windows);
let reset_seconds = if rate_limit_cooling {
rate_limit_reset_seconds.or_else(|| quota_windows_min_reset_seconds(&windows))
} else {
quota_windows_min_reset_seconds(&windows)
};
let reset_at = if rate_limit_cooling {
None
} else {
quota_windows_min_reset_at(&windows)
};
let exhausted_by_window = windows.iter().filter_map(Value::as_object).any(|window| {
window
.get("code")
.and_then(Value::as_str)
.is_some_and(|code| {
code.eq_ignore_ascii_case("daily") || code.eq_ignore_ascii_case("weekly")
})
&& window
.get("is_exhausted")
.and_then(admin_provider_quota_pure::coerce_json_bool)
.unwrap_or(false)
});
let exhausted = banned || quarantined || exhausted_by_window;
let (code, label, reason) = if banned {
(
"banned",
Some("账号已封禁"),
last_error
.clone()
.or_else(|| Some("账号被 Windsurf 标记为不可用".to_string())),
)
} else if quarantined {
(
"quarantined",
Some("账号隔离中"),
last_error
.clone()
.or_else(|| Some("账号处于隔离状态".to_string())),
)
} else if rate_limit_cooling {
(
"cooldown",
Some("冷却中"),
last_error.clone().or(rate_limit_reason),
)
} else if exhausted {
(
"exhausted",
Some("额度耗尽"),
Some("额度窗口已耗尽".to_string()),
)
} else {
("ok", None, last_error)
};
Some(json!({
"version": 2,
"provider_type": "windsurf",
"code": code,
"label": label,
"reason": reason,
"freshness": "fresh",
"source": source,
"observed_at": observed_at_unix_secs,
"exhausted": exhausted,
"usage_ratio": usage_ratio,
"updated_at": observed_at_unix_secs,
"reset_at": reset_at,
"reset_seconds": reset_seconds,
"plan_type": plan_type,
"allowed_models_count": allowed_models_count,
"rate_limit": rate_limit.unwrap_or(Value::Null),
"windows": windows,
}))
}
fn build_antigravity_quota_status_snapshot(
upstream_metadata: Option<&Value>,
source: &str,
@@ -1359,6 +1637,7 @@ pub(crate) fn sync_provider_key_quota_status_snapshot(
"codex" => build_codex_quota_status_snapshot(upstream_metadata, source),
"kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source),
"chatgpt_web" => build_chatgpt_web_quota_status_snapshot(upstream_metadata, source),
"windsurf" => build_windsurf_quota_status_snapshot(upstream_metadata, source),
"antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source),
"grok" => build_grok_quota_status_snapshot(upstream_metadata, source),
"gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source),
@@ -2298,6 +2577,157 @@ mod tests {
assert_eq!(auto.get("used_value"), Some(&json!(90.0)));
}
#[test]
fn provider_key_status_snapshot_payload_backfills_windsurf_daily_and_weekly_quota() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"windsurf": {
"updated_at": 1_778_067_246u64,
"plan_name": "Pro",
"daily_remaining_percent": 40.0,
"weekly_remaining_percent": 65.0,
"daily_reset_at": 1_778_100_000u64,
"weekly_reset_at": 1_778_600_000u64,
"prompt_used": 12.0,
"prompt_limit": 100.0,
"prompt_remaining": 88.0,
"flex_used": 3.0,
"flex_limit": 10.0,
"flex_remaining": 7.0,
"allowed_models_count": 82
}
}));
let payload = provider_key_status_snapshot_payload(&key, "windsurf");
let quota = payload
.get("quota")
.and_then(Value::as_object)
.expect("quota snapshot should be object");
let windows = quota
.get("windows")
.and_then(Value::as_array)
.expect("windsurf quota windows should exist");
let daily = windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("daily")))
.expect("daily quota window should exist");
let weekly = windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("weekly")))
.expect("weekly quota window should exist");
assert_eq!(quota.get("provider_type"), Some(&json!("windsurf")));
assert_eq!(quota.get("code"), Some(&json!("ok")));
assert_eq!(quota.get("plan_type"), Some(&json!("Pro")));
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.6)));
assert_eq!(quota.get("reset_at"), Some(&json!(1_778_100_000u64)));
assert_eq!(daily.get("remaining_ratio"), Some(&json!(0.4)));
assert_eq!(daily.get("used_ratio"), Some(&json!(0.6)));
assert_eq!(daily.get("reset_seconds"), Some(&json!(32_754u64)));
assert_eq!(weekly.get("remaining_ratio"), Some(&json!(0.65)));
assert_eq!(weekly.get("used_ratio"), Some(&json!(0.35)));
assert_eq!(weekly.get("reset_seconds"), Some(&json!(532_754u64)));
assert_eq!(quota.get("allowed_models_count"), Some(&json!(82)));
}
#[test]
fn provider_key_status_snapshot_payload_treats_windsurf_rate_limit_as_cooldown() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"windsurf": {
"updated_at": 1_778_067_246u64,
"daily_remaining_percent": 80.0,
"rate_limit": {
"limited": true,
"retry_after_ms": 60_001u64,
"message": "slow down"
},
"last_error": "slow down"
}
}));
let payload = provider_key_status_snapshot_payload(&key, "windsurf");
let quota = payload
.get("quota")
.and_then(Value::as_object)
.expect("quota snapshot should be object");
let rate_window = quota
.get("windows")
.and_then(Value::as_array)
.and_then(|windows| {
windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("rate_limit")))
})
.expect("rate limit window should exist");
assert_eq!(quota.get("code"), Some(&json!("cooldown")));
assert_eq!(quota.get("exhausted"), Some(&json!(false)));
assert_eq!(quota.get("reset_seconds"), Some(&json!(61u64)));
assert_eq!(rate_window.get("is_exhausted"), Some(&json!(false)));
assert_eq!(rate_window.get("reset_seconds"), Some(&json!(61u64)));
}
#[test]
fn provider_key_status_snapshot_payload_marks_windsurf_banned_and_quarantined_blocking() {
let mut banned_key = sample_catalog_key();
banned_key.upstream_metadata = Some(json!({
"windsurf": {
"updated_at": 1_778_067_246u64,
"banned": true,
"reason": "forbidden"
}
}));
let banned_payload = provider_key_status_snapshot_payload(&banned_key, "windsurf");
assert_eq!(
banned_payload.pointer("/quota/code"),
Some(&json!("banned"))
);
assert_eq!(
banned_payload.pointer("/quota/exhausted"),
Some(&json!(true))
);
assert_eq!(
banned_payload.pointer("/account/code"),
Some(&json!("account_banned"))
);
assert_eq!(
banned_payload.pointer("/account/blocked"),
Some(&json!(true))
);
let mut quarantined_key = sample_catalog_key();
quarantined_key.upstream_metadata = Some(json!({
"windsurf": {
"updated_at": 1_778_067_246u64,
"quarantined": true
}
}));
let quarantined_payload =
provider_key_status_snapshot_payload(&quarantined_key, "windsurf");
assert_eq!(
quarantined_payload.pointer("/quota/code"),
Some(&json!("quarantined"))
);
assert_eq!(
quarantined_payload.pointer("/quota/exhausted"),
Some(&json!(true))
);
assert_eq!(
quarantined_payload.pointer("/account/code"),
Some(&json!("account_quarantined"))
);
assert_eq!(
quarantined_payload.pointer("/account/blocked"),
Some(&json!(true))
);
}
#[test]
fn provider_key_status_snapshot_payload_preserves_existing_materialized_quota_snapshot() {
let mut key = sample_catalog_key();

View File

@@ -115,7 +115,13 @@ fn key_has_auth_type_overrides(key: &StoredProviderCatalogKey) -> bool {
fn provider_uses_bearer_oauth_runtime(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code" | "codex" | "chatgpt_web" | "gemini_cli" | "antigravity" | "kiro"
"claude_code"
| "codex"
| "chatgpt_web"
| "gemini_cli"
| "antigravity"
| "kiro"
| "windsurf"
)
}
@@ -319,7 +325,6 @@ mod tests {
key.encrypted_auth_config = Some(r#"{"sso_token":"abc"}"#.to_string());
let semantics = provider_key_auth_semantics(&key, "grok");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
@@ -331,6 +336,21 @@ mod tests {
);
}
#[test]
fn recognizes_windsurf_oauth_as_bearer_runtime() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "windsurf");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn refresh_capability_requires_stored_refresh_token() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "codex");

View File

@@ -216,6 +216,23 @@ fn codex_quota_execution_result(request_id: &str) -> serde_json::Value {
})
}
fn windsurf_register_user_execution_result(request_id: &str) -> serde_json::Value {
json!({
"request_id": request_id,
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"apiKey": "devin-session-token$registered",
"name": "Windsurf User",
"apiServerUrl": "https://server.codeium.com"
}
}
})
}
fn assert_single_provider_oauth_refresh_token_plan<'a>(
plans: &'a [ExecutionPlan],
) -> &'a ExecutionPlan {
@@ -281,18 +298,404 @@ async fn gateway_handles_admin_provider_oauth_supported_types_locally_with_trust
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array");
assert_eq!(items.len(), 5);
assert_eq!(items.len(), 6);
assert_eq!(items[0]["provider_type"], "claude_code");
assert_eq!(items[1]["provider_type"], "codex");
assert_eq!(items[2]["provider_type"], "chatgpt_web");
assert_eq!(items[3]["provider_type"], "gemini_cli");
assert_eq!(items[4]["provider_type"], "antigravity");
assert_eq!(items[5]["provider_type"], "windsurf");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_provider_oauth_device_authorize_for_windsurf_browser() {
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
provider.provider_type = "windsurf".to_string();
let endpoint = sample_endpoint(
"endpoint-windsurf-chat",
"provider-windsurf",
"openai:chat",
"https://server.codeium.com",
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![endpoint],
vec![],
));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
));
let response = local_admin_provider_oauth_response(
&state,
http::Method::POST,
"/api/admin/provider-oauth/providers/provider-windsurf/device-authorize",
Some(json!({
"auth_type": "browser",
"login_option": "github",
"proxy_node_id": "proxy-node-windsurf"
})),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
let session_id = payload["session_id"]
.as_str()
.expect("session_id should exist");
assert_eq!(payload["auth_type"], "browser");
assert_eq!(payload["login_option"], "github");
assert_eq!(payload["redirect_uri"], "show-auth-token");
assert_eq!(payload["callback_required"], true);
let authorization_url = payload["verification_uri_complete"]
.as_str()
.expect("authorization url should exist");
let parsed = url::Url::parse(authorization_url).expect("authorization url should parse");
let params = parsed
.query_pairs()
.into_owned()
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
parsed.as_str().split('?').next(),
Some("https://windsurf.com/windsurf/signin")
);
assert_eq!(
params.get("response_type").map(String::as_str),
Some("token")
);
assert_eq!(params.get("state").map(String::as_str), Some(session_id));
assert_eq!(
params.get("redirect_uri").map(String::as_str),
Some("show-auth-token")
);
assert_eq!(
params.get("redirect_parameters_type").map(String::as_str),
Some("query")
);
let stored = state
.load_provider_oauth_device_session_for_tests(&format!("device_auth_session:{session_id}"))
.expect("device session should be stored");
let stored: serde_json::Value =
serde_json::from_str(&stored).expect("device session json should parse");
assert_eq!(stored["provider_id"], "provider-windsurf");
assert_eq!(stored["auth_type"], "browser");
assert_eq!(stored["social_provider"], "github");
assert_eq!(stored["redirect_uri"], "show-auth-token");
assert_eq!(stored["proxy_node_id"], "proxy-node-windsurf");
assert_eq!(stored["status"], "pending");
}
#[tokio::test]
async fn gateway_rejects_generic_oauth_start_for_windsurf_provider() {
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
provider.provider_type = "windsurf".to_string();
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![],
));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
));
let response = local_admin_provider_oauth_response(
&state,
http::Method::POST,
"/api/admin/provider-oauth/providers/provider-windsurf/start",
None,
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
assert!(
payload["detail"]
.as_str()
.is_some_and(|detail| detail.contains("浏览器登录")),
"payload={payload}"
);
}
#[tokio::test]
async fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_callback_token() {
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
let execution_plans_clone = Arc::clone(&execution_plans);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |Json(plan): Json<ExecutionPlan>| {
let execution_plans_inner = Arc::clone(&execution_plans_clone);
async move {
execution_plans_inner
.lock()
.expect("mutex should lock")
.push(plan.clone());
if plan.request_id == "provider-oauth:windsurf-register:new" {
return Json(windsurf_register_user_execution_result(&plan.request_id));
}
Json(json!({
"request_id": plan.request_id,
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {}
}
}))
}
}),
);
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
provider.provider_type = "windsurf".to_string();
let endpoint = sample_endpoint(
"endpoint-windsurf-chat",
"provider-windsurf",
"openai:chat",
"https://server.codeium.com",
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![endpoint],
vec![],
));
let mut proxy_node = sample_proxy_node("proxy-node-windsurf");
proxy_node.status = "online".to_string();
proxy_node.is_manual = true;
proxy_node.tunnel_mode = false;
proxy_node.tunnel_connected = false;
proxy_node.proxy_url = Some("http://proxy.example:8080".to_string());
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![proxy_node]));
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let state = build_state_with_execution_runtime_override(execution_runtime_url)
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository.clone(),
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
.attach_proxy_node_repository_for_tests(proxy_node_repository),
)
.with_provider_oauth_device_session_entry_for_tests(
"session-windsurf",
json!({
"provider_id": "provider-windsurf",
"region": "",
"client_id": "",
"client_secret": "",
"device_code": "",
"auth_type": "browser",
"social_provider": "google",
"code_verifier": null,
"redirect_uri": "show-auth-token",
"machine_id": "123e4567-e89b-12d3-a456-426614174000",
"interval": 5,
"expires_at_unix_secs": 4_102_444_800u64,
"status": "pending",
"proxy_node_id": "proxy-node-windsurf",
"created_at_unix_ms": 1_711_000_000u64,
"key_id": null,
"email": null,
"replaced": false,
"error_msg": null,
}),
);
let response = local_admin_provider_oauth_response(
&state,
http::Method::POST,
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
Some(json!({
"session_id": "session-windsurf",
"callback_url": "https://windsurf.com/show-auth-token?token=firebase-id-token&state=session-windsurf&provider=google"
})),
)
.await;
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(status, StatusCode::OK, "payload={payload}");
assert_eq!(payload["status"], "authorized");
assert_eq!(payload["replaced"], false);
let stored = state
.load_provider_oauth_device_session_for_tests("device_auth_session:session-windsurf")
.expect("device session should persist");
let stored: serde_json::Value =
serde_json::from_str(&stored).expect("device session json should parse");
assert_eq!(stored["status"], "authorized");
let key_id = stored["key_id"]
.as_str()
.expect("key_id should be stored")
.to_string();
assert_eq!(payload["key_id"], key_id);
let persisted = provider_catalog_repository
.list_keys_by_ids(std::slice::from_ref(&key_id))
.await
.expect("keys should load")
.into_iter()
.next()
.expect("persisted key should exist");
assert_eq!(persisted.auth_type, "oauth");
assert_eq!(
persisted.proxy,
Some(json!({"node_id": "proxy-node-windsurf", "enabled": true}))
);
let decrypted_api_key = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
.encrypted_api_key
.as_deref()
.expect("api key should be present"),
)
.expect("api key should decrypt");
assert_eq!(decrypted_api_key, "devin-session-token$registered");
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
.encrypted_auth_config
.as_deref()
.expect("auth config should exist"),
)
.expect("auth config should decrypt");
let auth_config: serde_json::Value =
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
assert_eq!(auth_config["provider_type"], "windsurf");
assert_eq!(auth_config["auth_method"], "browser");
assert_eq!(auth_config["register_source"], "new");
assert_eq!(auth_config["social_provider"], "google");
{
let plans = execution_plans.lock().expect("mutex should lock");
let register_plan = plans
.iter()
.find(|plan| plan.request_id == "provider-oauth:windsurf-register:new")
.expect("register plan should execute");
assert_eq!(register_plan.method, "POST");
assert_eq!(
register_plan
.body
.json_body
.as_ref()
.and_then(|body| body.get("firebase_id_token"))
.and_then(serde_json::Value::as_str),
Some("firebase-id-token")
);
assert_eq!(
register_plan
.proxy
.as_ref()
.and_then(|proxy| proxy.node_id.as_deref()),
Some("proxy-node-windsurf")
);
}
execution_runtime_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_windsurf_callback_state_mismatch_and_missing_token() {
let mut provider = sample_provider("provider-windsurf", "windsurf", 10);
provider.provider_type = "windsurf".to_string();
let endpoint = sample_endpoint(
"endpoint-windsurf-chat",
"provider-windsurf",
"openai:chat",
"https://server.codeium.com",
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![endpoint],
vec![],
));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
))
.with_provider_oauth_device_session_entry_for_tests(
"session-windsurf",
json!({
"provider_id": "provider-windsurf",
"region": "",
"client_id": "",
"client_secret": "",
"device_code": "",
"auth_type": "browser",
"social_provider": "google",
"code_verifier": null,
"redirect_uri": "show-auth-token",
"machine_id": "123e4567-e89b-12d3-a456-426614174000",
"interval": 5,
"expires_at_unix_secs": 4_102_444_800u64,
"status": "pending",
"proxy_node_id": null,
"created_at_unix_ms": 1_711_000_000u64,
"key_id": null,
"email": null,
"replaced": false,
"error_msg": null,
}),
);
let response = local_admin_provider_oauth_response(
&state,
http::Method::POST,
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
Some(json!({
"session_id": "session-windsurf",
"callback_url": "https://windsurf.com/show-auth-token?token=firebase-id-token&state=wrong-state"
})),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(payload["status"], "error");
assert!(payload["error"]
.as_str()
.is_some_and(|error| error.contains("state")));
let response = local_admin_provider_oauth_response(
&state,
http::Method::POST,
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
Some(json!({
"session_id": "session-windsurf",
"callback_url": "https://windsurf.com/show-auth-token?state=session-windsurf"
})),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(payload["status"], "error");
assert!(payload["error"]
.as_str()
.is_some_and(|error| error.contains("token")));
}
#[tokio::test]
async fn gateway_handles_admin_provider_oauth_device_authorize_locally_with_trusted_admin_principal(
) {

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 {

View File

@@ -79,6 +79,137 @@ export interface OAuthBatchImportTaskStatusResponse {
updated_at: number
}
export type BatchImportCredentialsNormalization =
| { ok: true; isBatch: boolean; credentials: string }
| { ok: false; message: string }
function getImportCredentialLines(text: string): Array<{ lineNumber: number; text: string }> {
return text
.split('\n')
.map((line, index) => ({ lineNumber: index + 1, text: line.trim() }))
.filter(line => line.text && !line.text.startsWith('#'))
}
function jsonParseErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function normalizeBatchImportItem(
value: unknown,
location: string,
): { ok: true; value: string | Record<string, unknown> } | { ok: false; message: string } {
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed) return { ok: true, value: trimmed }
return { ok: false, message: `${location} 不能为空字符串` }
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return { ok: true, value: value as Record<string, unknown> }
}
return { ok: false, message: `${location} 必须是 JSON 对象或字符串` }
}
function normalizeBatchImportArray(items: unknown[]): BatchImportCredentialsNormalization {
if (items.length === 0) {
return { ok: false, message: 'JSON 数组不能为空' }
}
const normalized: Array<string | Record<string, unknown>> = []
for (const [index, item] of items.entries()) {
const result = normalizeBatchImportItem(item, `JSON 数组第 ${index + 1}`)
if (!result.ok) return result
normalized.push(result.value)
}
return {
ok: true,
isBatch: true,
credentials: JSON.stringify(normalized),
}
}
function parseImportCredentialLines(
lines: Array<{ lineNumber: number; text: string }>,
): BatchImportCredentialsNormalization {
const normalized: Array<string | Record<string, unknown>> = []
for (const line of lines) {
const firstChar = line.text[0]
if (firstChar === '{' || firstChar === '[') {
let parsed: unknown
try {
parsed = JSON.parse(line.text)
} catch (error) {
return {
ok: false,
message: `JSON Lines 格式无效,请检查第 ${line.lineNumber} 行: ${jsonParseErrorMessage(error)}`,
}
}
if (Array.isArray(parsed)) {
for (const [index, item] of parsed.entries()) {
const result = normalizeBatchImportItem(item, `${line.lineNumber} 行数组第 ${index + 1}`)
if (!result.ok) return result
normalized.push(result.value)
}
continue
}
const result = normalizeBatchImportItem(parsed, `${line.lineNumber}`)
if (!result.ok) return result
normalized.push(result.value)
continue
}
normalized.push(line.text)
}
return normalizeBatchImportArray(normalized)
}
export function normalizeBatchImportCredentials(text: string): BatchImportCredentialsNormalization {
const trimmed = text.trim()
if (!trimmed) {
return { ok: false, message: '请输入凭据数据' }
}
const firstChar = trimmed[0]
if (firstChar === '[') {
try {
const parsed: unknown = JSON.parse(trimmed)
if (!Array.isArray(parsed)) {
return { ok: false, message: 'JSON 批量凭据必须是数组' }
}
return normalizeBatchImportArray(parsed)
} catch (error) {
return { ok: false, message: `JSON 数组格式无效: ${jsonParseErrorMessage(error)}` }
}
}
if (firstChar === '{') {
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return { ok: true, isBatch: false, credentials: trimmed }
}
return { ok: false, message: '单条 JSON 凭据必须是对象' }
} catch (error) {
const lines = getImportCredentialLines(trimmed)
if (lines.length > 1) {
return parseImportCredentialLines(lines)
}
return { ok: false, message: `JSON 格式无效: ${jsonParseErrorMessage(error)}` }
}
}
const lines = getImportCredentialLines(trimmed)
if (lines.length > 1) {
return parseImportCredentialLines(lines)
}
return { ok: true, isBatch: false, credentials: trimmed }
}
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data
@@ -102,8 +233,14 @@ export async function completeProviderLevelOAuth(
export async function importProviderRefreshToken(
providerId: string,
data: {
api_key?: string
apiKey?: string
token?: string
auth_token?: string
authToken?: string
refresh_token?: string
access_token?: string
password?: string
expires_at?: number
name?: string
proxy_node_id?: string
@@ -150,7 +287,8 @@ export async function getBatchImportOAuthTaskStatus(
export interface DeviceAuthorizeRequest {
start_url?: string
region?: string
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github'
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
login_option?: 'google' | 'github' | 'default'
redirect_uri?: string
proxy_node_id?: string
}
@@ -170,6 +308,7 @@ export interface DeviceAuthorizeResponse {
export interface DevicePollRequest {
session_id: string
callback_url?: string
token?: string
}
export interface DevicePollResponse {

View File

@@ -5,6 +5,7 @@ import type {
FailoverRulesConfig,
PoolAdvancedConfig,
ProviderConfig,
ProviderType,
ProviderWithEndpointsSummary,
ProxyConfig,
} from './types'
@@ -92,7 +93,7 @@ export async function updateProvider(
providerId: string,
data: Partial<{
name: string
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
provider_type: ProviderType
description: string | null
website: string
provider_priority: number
@@ -127,7 +128,7 @@ export async function updateProvider(
export async function createProvider(
data: {
name: string
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
provider_type?: ProviderType
description?: string
website?: string
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'

View File

@@ -370,6 +370,32 @@ export interface KiroUpstreamMetadata {
banned_at?: number // 封禁时间Unix 时间戳,秒)
}
// Windsurf 上游配额信息
export interface WindsurfUpstreamMetadata {
updated_at?: number
plan_name?: string
daily_remaining_percent?: number | null
weekly_remaining_percent?: number | null
daily_reset_at?: number | null
weekly_reset_at?: number | null
prompt_used?: number | null
prompt_limit?: number | null
prompt_remaining?: number | null
flex_used?: number | null
flex_limit?: number | null
flex_remaining?: number | null
allowed_models_count?: number | null
models?: Array<{
model_uid?: string | null
label?: string | null
provider?: string | null
supports_images?: boolean | null
credit_multiplier?: number | null
}> | null
rate_limit?: Record<string, unknown> | null
last_error?: string | null
}
export interface ChatGPTWebUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
plan_type?: string | null
@@ -439,6 +465,7 @@ export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
windsurf?: WindsurfUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
balance_query?: BalanceQueryUpstreamMetadata
@@ -569,7 +596,7 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'vertex_ai'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制null/undefined 表示不限制

View File

@@ -66,6 +66,8 @@ export interface QuotaStatusSnapshot {
plan_type?: string | null
pool_tier?: string | null
credits?: QuotaCreditsSnapshot | null
allowed_models_count?: number | null
rate_limit?: Record<string, unknown> | null
windows?: QuotaWindowSnapshot[] | null
}

View File

@@ -73,7 +73,7 @@
]"
@click="switchMode('oauth')"
>
{{ isKiroProvider ? '设备授权' : '获取授权' }}
{{ isDeviceBrowserProvider ? (isWindsurfProvider ? '浏览器登录' : '设备授权') : '获取授权' }}
</button>
<button
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
@@ -93,8 +93,132 @@
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<!-- Windsurf: 浏览器 session/poll 授权 -->
<template v-if="isWindsurfProvider">
<div class="space-y-4">
<div class="grid grid-cols-3 gap-1.5">
<button
v-for="opt in ([
{ key: 'default', label: '默认' },
{ key: 'google', label: 'Google' },
{ key: 'github', label: 'GitHub' },
] as const)"
:key="opt.key"
class="h-8 text-xs font-medium rounded-md border transition-colors"
:class="device.auth_type === opt.key
? 'border-primary bg-primary/5 text-foreground'
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/20'"
@click="selectWindsurfLoginOption(opt.key)"
>
{{ opt.label }}
</button>
</div>
<div
v-if="device.status === 'error' || device.status === 'expired'"
class="rounded-xl border border-destructive/20 bg-destructive/5 p-5"
>
<div class="flex flex-col items-center text-center space-y-3">
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle class="w-5 h-5 text-destructive" />
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-destructive">
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
</p>
<p class="text-xs text-muted-foreground">
{{ device.error || '请重试' }}
</p>
</div>
<Button
size="sm"
variant="outline"
@click="resetDevice"
>
重新开始
</Button>
</div>
</div>
<div
v-else-if="device.starting && !device.session_id"
class="flex items-center justify-center py-12"
>
<div class="text-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
<p class="text-xs text-muted-foreground">
正在准备登录...
</p>
</div>
</div>
<div
v-else
class="space-y-4"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
<span class="text-xs font-medium">前往登录</span>
</div>
<div class="flex gap-2 pl-6">
<Button
size="sm"
:disabled="device.starting || device.completing || !device.verification_uri_complete"
@click="openDeviceVerificationUrl"
>
<ExternalLink class="w-3 h-3 mr-1" />
打开
</Button>
<Button
size="sm"
variant="outline"
:disabled="device.starting || device.completing || !device.verification_uri_complete"
@click="copyToClipboard(device.verification_uri_complete)"
>
<Copy class="w-3 h-3 mr-1" />
复制
</Button>
<Button
v-if="!device.session_id"
size="sm"
variant="outline"
:disabled="device.starting"
@click="startDeviceAuth"
>
开始
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
<span class="text-xs font-medium">粘贴回调 URL 或 token</span>
</div>
<div class="pl-6">
<Textarea
v-model="device.callback_url"
:disabled="device.completing"
:placeholder="deviceCallbackPlaceholder"
class="min-h-[150px] text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
</div>
<div
v-if="device.session_id && device.status === 'pending'"
class="pl-6 flex items-center gap-1.5 text-[11px] text-muted-foreground"
>
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
<span>会话剩余 {{ deviceCountdownFormatted }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- Kiro: 设备授权模式 -->
<template v-if="isKiroProvider">
<template v-else-if="isKiroProvider">
<div class="space-y-3">
<!-- 授权类型切换 -->
<div class="grid grid-cols-2 gap-1.5">
@@ -198,7 +322,7 @@
<Textarea
v-model="device.callback_url"
:disabled="device.completing"
:placeholder="kiroSocialCallbackPlaceholder"
:placeholder="deviceCallbackPlaceholder"
class="h-full min-h-0 overflow-y-auto text-xs font-mono break-all !rounded-xl"
spellcheck="false"
/>
@@ -527,15 +651,15 @@
取消
</Button>
<Button
v-if="mode === 'oauth' && showAuthorizationMode && !isKiroProvider"
v-if="mode === 'oauth' && showAuthorizationMode && !isDeviceBrowserProvider"
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
{{ oauth.completing ? '验证中...' : '验证' }}
</Button>
<Button
v-if="mode === 'oauth' && isKiroSocialManualCallbackMode"
:disabled="!canCompleteKiroSocialDeviceAuth"
v-if="mode === 'oauth' && isManualDeviceCallbackMode"
:disabled="!canCompleteDeviceAuth"
@click="completeDeviceAuth"
>
{{ device.completing ? '验证中...' : '验证' }}
@@ -577,6 +701,7 @@ import {
getBatchImportOAuthTaskStatus,
startDeviceAuthorize,
pollDeviceAuthorize,
normalizeBatchImportCredentials,
getAwsRegions,
} from '@/api/endpoints'
import type {
@@ -678,7 +803,8 @@ let oauthInitRequestId = 0
let oauthCompleteRequestId = 0
// 设备授权状态
type DeviceAuthType = 'google' | 'github' | 'builder_id' | 'identity_center'
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center'
type WindsurfLoginOption = 'default' | 'google' | 'github'
interface DeviceAuthState {
auth_type: DeviceAuthType
@@ -741,6 +867,8 @@ const isOpen = computed(() => props.open)
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
const showAuthorizationMode = computed(() => !isGrokProvider.value)
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
@@ -752,14 +880,20 @@ const isKiroSocialManualCallbackMode = computed(() =>
isKiroProvider.value && isSocialDeviceAuth.value
)
const isKiroSocialManualCallbackPending = computed(() =>
isKiroSocialManualCallbackMode.value
const isManualDeviceCallbackMode = computed(() =>
isKiroSocialManualCallbackMode.value || isWindsurfProvider.value
)
const isManualDeviceCallbackPending = computed(() =>
isManualDeviceCallbackMode.value
&& device.value.session_id.length > 0
&& device.value.status === 'pending'
)
const kiroSocialCallbackPlaceholder = computed(() =>
`http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
const deviceCallbackPlaceholder = computed(() =>
isWindsurfProvider.value
? `粘贴包含 token=...&state=... 的回调 URLsession token/apiKey 也可直接粘贴,普通 token 请用导入授权`
: `http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
)
const deviceCountdownFormatted = computed(() => {
@@ -779,8 +913,8 @@ const canCompleteOAuth = computed(() => {
return !oauthBusy.value
})
const canCompleteKiroSocialDeviceAuth = computed(() => {
if (!isKiroSocialManualCallbackPending.value) return false
const canCompleteDeviceAuth = computed(() => {
if (!isManualDeviceCallbackPending.value) return false
if (!device.value.callback_url.trim()) return false
return !device.value.starting && !device.value.completing
})
@@ -957,6 +1091,7 @@ function resetDeviceRuntimeState() {
}
function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
if (!isKiroProvider.value) return false
if (device.value.starting) {
return !isSocialDeviceAuth.value
}
@@ -967,6 +1102,14 @@ function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
return true
}
function selectWindsurfLoginOption(loginOption: WindsurfLoginOption) {
if (!isWindsurfProvider.value) return
if (device.value.auth_type === loginOption && device.value.session_id && device.value.status === 'pending') return
deviceAuthRequestId += 1
resetDeviceRuntimeState()
device.value.auth_type = loginOption
}
function selectDeviceAuthType(authType: DeviceAuthType) {
if (device.value.auth_type === authType) return
if (isKiroDeviceAuthOptionDisabled(authType)) return
@@ -985,11 +1128,11 @@ function resetDevice() {
totp.stop()
const { auth_type, start_url, region, totp_secret } = device.value
device.value = createInitialDeviceState()
device.value.auth_type = auth_type
device.value.auth_type = isWindsurfProvider.value ? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default') : auth_type
device.value.start_url = start_url
device.value.region = region
device.value.totp_secret = totp_secret
if (device.value.auth_type === 'google' || device.value.auth_type === 'github') {
if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
void ensureKiroSocialDeviceAuth()
}
}
@@ -1003,6 +1146,9 @@ function resetForm() {
stopDevicePolling()
totp.stop()
device.value = createInitialDeviceState()
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
}
importText.value = ''
importing.value = false
importTask.value = null
@@ -1046,7 +1192,7 @@ function openAuthorizationUrl() {
async function initOAuth() {
if (!props.providerId) return
if (!showAuthorizationMode.value) return
if (isKiroProvider.value) return
if (isDeviceBrowserProvider.value) return
if (oauth.value.starting) return
const requestId = ++oauthInitRequestId
@@ -1095,35 +1241,12 @@ async function handleCompleteOAuth() {
}
}
// 检测是否为批量导入格式
function isBatchImport(text: string): boolean {
const trimmed = text.trim()
// JSON 数组(含单元素数组)
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) && parsed.length >= 1
} catch {
return false
}
}
// 单个 JSON 对象(可能是 pretty-printed 多行)不算批量导入
if (trimmed.startsWith('{')) {
try {
JSON.parse(trimmed)
return false // 可解析的单个 JSON 对象,走单条导入
} catch {
// 解析失败:可能是多个 JSON 对象JSON Lines 格式),继续检查
}
}
// 多行文本(纯 Token 一行一个)
const lines = trimmed.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
return lines.length > 1
}
function parseImportText(text: string): {
api_key?: string
token?: string
refresh_token?: string
access_token?: string
password?: string
expires_at?: number
name?: string
email?: string
@@ -1154,6 +1277,35 @@ function parseImportText(text: string): {
}
}
if (isWindsurfProvider.value) {
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const apiKey = normalizeStringField(obj.api_key) ?? normalizeStringField(obj.apiKey)
const token = normalizeStringField(obj.token) ?? normalizeStringField(obj.auth_token) ?? normalizeStringField(obj.authToken)
const refreshToken = normalizeStringField(obj.refresh_token) ?? normalizeStringField(obj.refreshToken)
const accessToken = normalizeStringField(obj.access_token) ?? normalizeStringField(obj.accessToken)
const email = normalizeStringField(obj.email)
const password = normalizeStringField(obj.password)
if (apiKey || token || refreshToken || accessToken || (email && password)) {
return {
api_key: apiKey,
token,
refresh_token: refreshToken,
access_token: accessToken,
email,
password,
name: normalizeStringField(obj.name) ?? email,
}
}
}
} catch {
// Not JSON: treat as token copied from show-auth-token.
}
return { token: trimmed }
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (typeof parsed === 'object' && parsed !== null) {
@@ -1325,13 +1477,19 @@ async function handleImport() {
return
}
const normalizedCredentials = normalizeBatchImportCredentials(inputText)
if (!normalizedCredentials.ok) {
showError(normalizedCredentials.message, '格式错误')
return
}
importing.value = true
let keepImporting = false
try {
const proxyNodeId = selectedProxyNodeId.value || undefined
// Kiro 的单条 JSON 凭据也必须走 batch-import 路径,后端需要完整 auth_config。
if (isKiroProvider.value || isBatchImport(inputText)) {
const task = await startBatchImportOAuthTask(props.providerId, inputText, proxyNodeId)
if (isKiroProvider.value || normalizedCredentials.isBatch) {
const task = await startBatchImportOAuthTask(props.providerId, normalizedCredentials.credentials, proxyNodeId)
importTask.value = {
task_id: task.task_id,
provider_id: props.providerId,
@@ -1356,7 +1514,7 @@ async function handleImport() {
scheduleImportPoll(task.task_id, 400)
} else {
// 单条导入
const parsed = parseImportText(inputText)
const parsed = parseImportText(normalizedCredentials.credentials)
if (!parsed) {
showError('无法解析输入内容,请检查格式', '格式错误')
return
@@ -1413,12 +1571,18 @@ async function startDeviceAuth() {
device.value.starting = true
device.value.error = ''
try {
const isWindsurf = isWindsurfProvider.value
const isBuilderID = requestedAuthType === 'builder_id'
const isSocial = requestedAuthType === 'google' || requestedAuthType === 'github'
const windsurfLoginOption: WindsurfLoginOption = isSocial ? requestedAuthType : 'default'
const authTypeForRequest = isWindsurf
? 'browser'
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
const resp = await startDeviceAuthorize(props.providerId, {
auth_type: requestedAuthType,
start_url: isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined)),
region: isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined),
auth_type: authTypeForRequest,
login_option: isWindsurf ? windsurfLoginOption : undefined,
start_url: isWindsurf ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
region: isWindsurf ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
@@ -1428,7 +1592,7 @@ async function startDeviceAuth() {
device.value.verification_uri_complete = resp.verification_uri_complete
device.value.expires_at = Date.now() + resp.expires_in * 1000
device.value.interval = resp.interval || 5
device.value.callback_required = resp.callback_required === true || isSocial
device.value.callback_required = resp.callback_required === true || isSocial || isWindsurf
device.value.status = 'pending'
startCountdown()
if (!device.value.callback_required) {
@@ -1464,7 +1628,7 @@ function scheduleDevicePoll() {
}
async function completeDeviceAuth() {
if (device.value.completing || !canCompleteKiroSocialDeviceAuth.value) return
if (device.value.completing || !canCompleteDeviceAuth.value) return
device.value.completing = true
try {
await pollDevice(true)
@@ -1473,13 +1637,39 @@ async function completeDeviceAuth() {
}
}
function normalizeWindsurfSubmittedCredential(value: string): { callback_url?: string, token?: string } {
const trimmed = value.trim()
if (!trimmed) return {}
if (/^https?:\/\//i.test(trimmed)) {
return { callback_url: trimmed }
}
const query = trimmed.replace(/^[?#&]+/, '')
const params = new URLSearchParams(query)
const hasTokenParam = ['token', 'auth_token', 'access_token'].some(key => params.has(key))
const hasStateParam = params.has('state')
if (hasTokenParam && hasStateParam) {
return { callback_url: `https://windsurf.com/show-auth-token?${query}` }
}
if (hasTokenParam) {
return { token: params.get('token') || params.get('auth_token') || params.get('access_token') || trimmed }
}
return { token: trimmed }
}
async function pollDevice(withCallback = false) {
if (!props.providerId || !device.value.session_id || device.value.status !== 'pending') return
try {
const submittedCredential = withCallback ? device.value.callback_url.trim() : ''
const windsurfSubmitted = isWindsurfProvider.value
? normalizeWindsurfSubmittedCredential(submittedCredential)
: {}
const result = await pollDeviceAuthorize(props.providerId, {
session_id: device.value.session_id,
callback_url: withCallback ? device.value.callback_url.trim() : undefined,
callback_url: withCallback ? (windsurfSubmitted.callback_url || (!isWindsurfProvider.value ? submittedCredential : undefined)) : undefined,
token: withCallback ? windsurfSubmitted.token : undefined,
})
switch (result.status) {
@@ -1535,7 +1725,9 @@ watch(() => props.open, (newOpen) => {
if (!showAuthorizationMode.value) {
return
}
if (isKiroProvider.value) {
if (isWindsurfProvider.value) {
device.value.auth_type = 'default'
} else if (isKiroProvider.value) {
void ensureKiroSocialDeviceAuth()
} else {
initOAuth()
@@ -1552,7 +1744,11 @@ watch(
mode.value = 'import'
return
}
if (props.open && isKiroProvider.value && mode.value === 'oauth') {
if (props.open && isWindsurfProvider.value && mode.value === 'oauth') {
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
? device.value.auth_type
: 'default'
} else if (props.open && isKiroProvider.value && mode.value === 'oauth') {
void ensureKiroSocialDeviceAuth()
}
},

View File

@@ -960,6 +960,138 @@
</div>
</template>
</div>
<!-- Windsurf 上游额度信息 -->
<div
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
class="mt-2 p-2 rounded-md"
:class="isWindsurfUnavailableKey(key) ? 'bg-destructive/10 border border-destructive/30' : (isWindsurfExhaustedKey(key) ? 'bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/50' : 'bg-muted/30')"
>
<div
v-if="isWindsurfUnavailableKey(key)"
class="flex items-center gap-2 text-destructive"
>
<ShieldX class="w-4 h-4 shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-[11px] font-medium">
账号不可用
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.last_error"
class="text-[10px] text-destructive/80 truncate"
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
>
{{ getWindsurfQuotaDisplay(key)?.last_error }}
</div>
</div>
</div>
<template v-else>
<div
v-if="isWindsurfExhaustedKey(key)"
class="mb-2 flex items-center gap-2 text-amber-700 dark:text-amber-300"
>
<ShieldX class="w-4 h-4 shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-[11px] font-medium">
{{ getWindsurfQuotaStatusLabel(key) }}
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.last_error"
class="text-[10px] text-amber-700/80 dark:text-amber-300/80 truncate"
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
>
{{ getWindsurfQuotaDisplay(key)?.last_error }}
</div>
</div>
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-[10px] text-muted-foreground">账号配额</span>
<div class="flex items-center gap-1">
<RefreshCw
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="getWindsurfQuotaDisplay(key)?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatKiroUpdatedAt(getWindsurfQuotaDisplay(key)?.updated_at || 0) }}
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div v-if="getWindsurfQuotaDisplay(key)?.daily_remaining_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">日额度</span>
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)">
{{ (getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)"
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0, 0)}%` }"
/>
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.daily_reset_at"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.daily_reset_at || 0) }}重置
</div>
</div>
<div v-if="getWindsurfQuotaDisplay(key)?.weekly_remaining_percent !== undefined">
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">周额度</span>
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)">
{{ (getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0).toFixed(1) }}%
</span>
</div>
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)"
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0, 0)}%` }"
/>
</div>
<div
v-if="getWindsurfQuotaDisplay(key)?.weekly_reset_at"
class="text-[9px] text-muted-foreground/70 mt-0.5"
>
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.weekly_reset_at || 0) }}重置
</div>
</div>
</div>
<div
v-if="hasWindsurfPromptQuota(key) || hasWindsurfFlexQuota(key)"
class="mt-2 flex items-center gap-3 text-[9px] text-muted-foreground/70"
>
<span v-if="hasWindsurfPromptQuota(key)">
Prompt {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_used || 0) }} /
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_limit || 0) }}
</span>
<span v-if="hasWindsurfFlexQuota(key)">
Flex {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_used || 0) }} /
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_limit || 0) }}
</span>
</div>
<div
v-if="hasWindsurfModelCount(key) || hasWindsurfModelPreview(key)"
class="mt-2 flex items-center justify-between gap-2 text-[9px] text-muted-foreground/70"
>
<span>
模型 {{ getWindsurfQuotaDisplay(key)?.allowed_models_count ?? getWindsurfQuotaDisplay(key)?.models?.length }} 个
</span>
<span
v-if="getWindsurfModelPreview(key)"
class="truncate"
:title="getWindsurfModelPreview(key) || ''"
>
{{ getWindsurfModelPreview(key) }}
</span>
</div>
</template>
</div>
<!-- ChatGPT Web 上游额度信息(生图配额) -->
<div
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
@@ -1357,6 +1489,7 @@ import type {
ChatGPTWebUpstreamMetadata,
GrokUpstreamMetadata,
KiroUpstreamMetadata,
WindsurfUpstreamMetadata,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
} from '@/api/endpoints/types'
@@ -2185,7 +2318,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
}
}
// Codex / Antigravity / Kiro / ChatGPT Web打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发)
// Codex / Antigravity / Kiro / Windsurf / ChatGPT Web打开抽屉后自动后台刷新配额缓存缺失/过期,或 Token 即将过期时触发)
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
@@ -2204,7 +2337,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
function getQuotaSnapshotForProvider(
key: EndpointAPIKey,
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
@@ -2463,11 +2596,137 @@ function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
return Object.keys(display).length > 0 ? display : null
}
type WindsurfQuotaDisplay = WindsurfUpstreamMetadata & {
daily_used_percent?: number
weekly_used_percent?: number
}
function getWindsurfQuotaDisplay(key: EndpointAPIKey): WindsurfQuotaDisplay | null {
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
const upstream = key.upstream_metadata?.windsurf
if (!quota && !upstream) return null
const display: WindsurfQuotaDisplay = {}
const updatedAt = getQuotaSnapshotUpdatedAt(quota) ?? upstream?.updated_at
if (updatedAt !== undefined) display.updated_at = updatedAt
if (quota?.plan_type) display.plan_name = quota.plan_type
else if (upstream?.plan_name) display.plan_name = upstream.plan_name
if (quota?.reason) display.last_error = quota.reason
else if (upstream?.last_error) display.last_error = upstream.last_error
if (typeof quota?.allowed_models_count === 'number') display.allowed_models_count = quota.allowed_models_count
else if (typeof upstream?.allowed_models_count === 'number') display.allowed_models_count = upstream.allowed_models_count
if (quota?.rate_limit) display.rate_limit = quota.rate_limit
else if (upstream?.rate_limit) display.rate_limit = upstream.rate_limit
if (Array.isArray(upstream?.models)) display.models = upstream.models
const dailyWindow = getQuotaWindow(quota, 'daily')
const dailyRemaining = getQuotaWindowRemainingPercent(dailyWindow)
const dailyUsed = getQuotaWindowUsedPercent(dailyWindow)
if (dailyRemaining !== undefined) display.daily_remaining_percent = dailyRemaining
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_remaining_percent = upstream.daily_remaining_percent
if (dailyUsed !== undefined) display.daily_used_percent = dailyUsed
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_used_percent = Math.max(100 - upstream.daily_remaining_percent, 0)
const dailyResetAt = getQuotaWindowResetAt(dailyWindow)
if (dailyResetAt !== undefined) display.daily_reset_at = dailyResetAt
else if (typeof upstream?.daily_reset_at === 'number') display.daily_reset_at = upstream.daily_reset_at
const weeklyWindow = getQuotaWindow(quota, 'weekly')
const weeklyRemaining = getQuotaWindowRemainingPercent(weeklyWindow)
const weeklyUsed = getQuotaWindowUsedPercent(weeklyWindow)
if (weeklyRemaining !== undefined) display.weekly_remaining_percent = weeklyRemaining
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_remaining_percent = upstream.weekly_remaining_percent
if (weeklyUsed !== undefined) display.weekly_used_percent = weeklyUsed
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_used_percent = Math.max(100 - upstream.weekly_remaining_percent, 0)
const weeklyResetAt = getQuotaWindowResetAt(weeklyWindow)
if (weeklyResetAt !== undefined) display.weekly_reset_at = weeklyResetAt
else if (typeof upstream?.weekly_reset_at === 'number') display.weekly_reset_at = upstream.weekly_reset_at
const promptWindow = getQuotaWindow(quota, 'prompt')
if (typeof promptWindow?.used_value === 'number') display.prompt_used = promptWindow.used_value
else if (typeof upstream?.prompt_used === 'number') display.prompt_used = upstream.prompt_used
if (typeof promptWindow?.limit_value === 'number') display.prompt_limit = promptWindow.limit_value
else if (typeof upstream?.prompt_limit === 'number') display.prompt_limit = upstream.prompt_limit
if (typeof promptWindow?.remaining_value === 'number') display.prompt_remaining = promptWindow.remaining_value
else if (typeof upstream?.prompt_remaining === 'number') display.prompt_remaining = upstream.prompt_remaining
const flexWindow = getQuotaWindow(quota, 'flex')
if (typeof flexWindow?.used_value === 'number') display.flex_used = flexWindow.used_value
else if (typeof upstream?.flex_used === 'number') display.flex_used = upstream.flex_used
if (typeof flexWindow?.limit_value === 'number') display.flex_limit = flexWindow.limit_value
else if (typeof upstream?.flex_limit === 'number') display.flex_limit = upstream.flex_limit
if (typeof flexWindow?.remaining_value === 'number') display.flex_remaining = flexWindow.remaining_value
else if (typeof upstream?.flex_remaining === 'number') display.flex_remaining = upstream.flex_remaining
return Object.keys(display).length > 0 ? display : null
}
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
const grok = getGrokQuotaDisplay(key)
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
}
function hasWindsurfQuotaDisplayData(key: EndpointAPIKey): boolean {
const windsurf = getWindsurfQuotaDisplay(key)
return !!windsurf && (
windsurf.daily_remaining_percent !== undefined
|| windsurf.weekly_remaining_percent !== undefined
|| windsurf.prompt_limit !== undefined
|| windsurf.flex_limit !== undefined
|| windsurf.allowed_models_count !== undefined
|| windsurf.rate_limit !== undefined
|| !!windsurf.last_error
|| (Array.isArray(windsurf.models) && windsurf.models.length > 0)
)
}
function isWindsurfUnavailableKey(key: EndpointAPIKey): boolean {
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
return code === 'banned' || code === 'forbidden' || code === 'quarantined'
}
function isWindsurfExhaustedKey(key: EndpointAPIKey): boolean {
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown'
}
function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
const label = quota?.label?.trim()
if (label) return label
const code = String(quota?.code || '').trim().toLowerCase()
return code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown' ? '速率受限' : '额度耗尽'
}
function getWindsurfModelPreview(key: EndpointAPIKey): string | null {
const models = getWindsurfQuotaDisplay(key)?.models
if (!Array.isArray(models) || models.length === 0) return null
return models
.slice(0, 3)
.map(model => (model.label || model.model_uid || '').trim())
.filter(Boolean)
.join(' / ') || null
}
function hasFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function hasWindsurfPromptQuota(key: EndpointAPIKey): boolean {
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.prompt_limit)
}
function hasWindsurfFlexQuota(key: EndpointAPIKey): boolean {
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.flex_limit)
}
function hasWindsurfModelCount(key: EndpointAPIKey): boolean {
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.allowed_models_count)
}
function hasWindsurfModelPreview(key: EndpointAPIKey): boolean {
return !!getWindsurfModelPreview(key)
}
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
image_quota_remaining_percent?: number
image_quota_used_percent?: number
@@ -2680,7 +2939,7 @@ function shouldAutoRefreshCodexQuota(): boolean {
return false
}
// 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro / ChatGPT Web
// 检查 OAuth Token 是否即将过期Codex / Antigravity / Kiro / Windsurf / ChatGPT Web
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
@@ -2757,6 +3016,28 @@ function shouldAutoRefreshGrokQuota(): boolean {
return false
}
function shouldAutoRefreshWindsurfQuota(): boolean {
if (provider.value?.provider_type !== 'windsurf') return false
const now = Math.floor(Date.now() / 1000)
for (const { key } of allKeys.value) {
if (!key.is_active) continue
if (isTokenExpiringSoon(key, now)) return true
if (!hasWindsurfQuotaDisplayData(key)) {
return true
}
const updatedAt = getWindsurfQuotaDisplay(key)?.updated_at
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
return true
}
}
return false
}
function shouldAutoRefreshChatGPTWebQuota(): boolean {
if (provider.value?.provider_type !== 'chatgpt_web') return false
const now = Math.floor(Date.now() / 1000)
@@ -2856,14 +3137,14 @@ function applyQuotaResults(
return applied
}
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro 和 ChatGPT Web
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro、Windsurf 和 ChatGPT Web
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
const providerId = props.providerId
if (!providerId) return
if (refreshingQuota.value) return
const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
// 检查是否需要刷新
let shouldRefresh = false
@@ -2875,6 +3156,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
shouldRefresh = shouldAutoRefreshKiroQuota()
} else if (providerType === 'grok') {
shouldRefresh = shouldAutoRefreshGrokQuota()
} else if (providerType === 'windsurf') {
shouldRefresh = shouldAutoRefreshWindsurfQuota()
} else if (providerType === 'chatgpt_web') {
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
}
@@ -2890,6 +3173,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
} else if (providerType === 'grok') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
} else if (providerType === 'windsurf') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
} else if (providerType === 'chatgpt_web') {
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
}

View File

@@ -66,6 +66,9 @@
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="windsurf">
Windsurf
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
@@ -96,6 +99,9 @@
<SelectItem value="kiro">
Kiro
</SelectItem>
<SelectItem value="windsurf">
Windsurf
</SelectItem>
<SelectItem value="antigravity">
Antigravity
</SelectItem>
@@ -342,6 +348,7 @@ import {
createProvider,
normalizePoolAdvancedConfig,
updateProvider,
type ProviderType,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
@@ -377,7 +384,7 @@ const defaultPriority = computed(() => {
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok',
provider_type: 'custom' as ProviderType,
description: '',
website: '',
// 计费配置

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { normalizeBatchImportCredentials } from '@/api/endpoints/provider_oauth'
import { isKeyManagedProviderType, isOAuthAccountProviderType } from '../providerTypeUtils'
describe('providerTypeUtils', () => {
@@ -14,4 +15,47 @@ describe('providerTypeUtils', () => {
expect(isOAuthAccountProviderType('GROK')).toBe(true)
expect(isKeyManagedProviderType('grok')).toBe(false)
})
it('treats Windsurf as an OAuth account provider', () => {
expect(isOAuthAccountProviderType('windsurf')).toBe(true)
expect(isOAuthAccountProviderType('Windsurf')).toBe(true)
expect(isKeyManagedProviderType('windsurf')).toBe(false)
})
})
describe('normalizeBatchImportCredentials', () => {
it('converts JSON Lines objects into a JSON array payload', () => {
const result = normalizeBatchImportCredentials([
'{"refresh_token":"rt-1","email":"one@example.com"}',
'{"token":"token-2","email":"two@example.com"}',
].join('\n'))
expect(result).toEqual({
ok: true,
isBatch: true,
credentials: JSON.stringify([
{ refresh_token: 'rt-1', email: 'one@example.com' },
{ token: 'token-2', email: 'two@example.com' },
]),
})
})
it('rejects malformed JSON Lines instead of treating them as raw tokens', () => {
const result = normalizeBatchImportCredentials('{"refresh_token":"rt-1"}\n{"refresh_token":')
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain('第 2 行')
}
})
it('converts multiple raw token lines into a JSON array payload', () => {
const result = normalizeBatchImportCredentials('token-a\n# comment\ntoken-b')
expect(result).toEqual({
ok: true,
isBatch: true,
credentials: JSON.stringify(['token-a', 'token-b']),
})
})
})

View File

@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
'antigravity',
'kiro',
'grok',
'windsurf',
])
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>

View File

@@ -104,4 +104,74 @@ describe('providerKeyQuota', () => {
},
}, 'grok')).toBe('Auto剩余 40.0% (60/150) | Heavy剩余 0.0% (0/20)')
})
it('surfaces Windsurf hard account states', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'quarantined',
label: '账号隔离中',
exhausted: false,
},
},
}, 'windsurf')).toBe('账号隔离中')
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'cooldown',
label: '冷却中',
exhausted: false,
},
},
}, 'windsurf')).toBe('冷却中')
})
it('includes Windsurf quota windows and model availability in display text', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'ok',
exhausted: false,
allowed_models_count: 7,
windows: [
{
code: 'daily',
remaining_ratio: 0.75,
},
{
code: 'weekly',
remaining_ratio: 0.5,
},
{
code: 'prompt',
remaining_value: 12,
limit_value: 20,
},
{
code: 'flex',
used_value: 2,
limit_value: 5,
},
],
},
},
}, 'windsurf')).toBe('日剩余 75.0% | 周剩余 50.0% | Prompt 剩余 12/20 | Flex 剩余 3/5 | 可用模型 7 个')
})
it('uses Windsurf model availability when no quota window is present', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'windsurf',
code: 'ok',
exhausted: false,
allowed_models_count: 3,
},
},
}, 'windsurf')).toBe('可用模型 3 个')
})
})

View File

@@ -214,6 +214,53 @@ function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
return normalizeText(quota.label)
}
function getWindsurfQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'banned' || code === 'forbidden' || code === 'quarantined') {
return normalizeText(quota.label) || '账号不可用'
}
if (code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown') {
return normalizeText(quota.label) || '速率受限'
}
if (code === 'exhausted') {
return normalizeText(quota.label) || '额度已耗尽'
}
const parts: string[] = []
const dailyRemaining = getQuotaWindowRemainingPercent(getQuotaWindow(quota, 'daily'))
const weeklyRemaining = getQuotaWindowRemainingPercent(getQuotaWindow(quota, 'weekly'))
if (dailyRemaining != null) parts.push(`日剩余 ${formatPercent(dailyRemaining)}`)
if (weeklyRemaining != null) parts.push(`周剩余 ${formatPercent(weeklyRemaining)}`)
for (const [label, code] of [
['Prompt', 'prompt'],
['Flex', 'flex'],
] as const) {
const window = getQuotaWindow(quota, code)
if (!window) continue
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
parts.push(`${label} 剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`)
continue
}
if (typeof window.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
parts.push(`${label} 剩余 ${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`)
continue
}
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent != null) {
parts.push(`${label} 剩余 ${formatPercent(remainingPercent)}`)
}
}
if (typeof quota.allowed_models_count === 'number') {
parts.push(`可用模型 ${quota.allowed_models_count}`)
}
if (parts.length > 0) return parts.join(' | ')
return normalizeText(quota.label)
}
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
const code = normalizeText(quota.code)?.toLowerCase()
if (code === 'forbidden') {
@@ -312,6 +359,8 @@ export function getQuotaSnapshotFallbackText(
return getKiroQuotaText(quota)
case 'grok':
return getGrokQuotaText(quota)
case 'windsurf':
return getWindsurfQuotaText(quota)
case 'antigravity':
return getAntigravityQuotaText(quota)
case 'gemini_cli':

View File

@@ -2076,6 +2076,7 @@ const showAccountQuotaColumn = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
@@ -2475,6 +2476,7 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
|| selectedProviderType.value === 'chatgpt_web'
@@ -3622,11 +3624,15 @@ function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title:
if (!quota) return null
const code = String(quota.code || '').trim().toLowerCase()
if (code !== 'banned' && code !== 'forbidden') return null
if (!['banned', 'forbidden', 'quarantined', 'rate_limited', 'exhausted'].includes(code)) return null
let label = String(quota.label || '').trim()
if (!label) {
label = code === 'banned' ? '账号封禁' : '访问受限'
if (code === 'banned') label = '账号封禁'
else if (code === 'forbidden') label = '访问受限'
else if (code === 'quarantined') label = '账号隔离'
else if (code === 'rate_limited') label = '速率受限'
else label = '额度耗尽'
} else if (label === '账号已封禁' || label === '封禁') {
label = '账号封禁'
}
@@ -3683,6 +3689,7 @@ function normalizeQuotaLabel(label: string): string {
}
function getQuotaProgressLabel(label: string): string {
if (label === '日') return '日'
if (label === '5H') return '5H'
if (label === '周') return '周'
if (label === 'Spark5H') return 'Spark5H'
@@ -3693,7 +3700,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (!['5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (!['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -3747,14 +3754,19 @@ function getQuotaLabelOrder(label: string): number {
if (label === 'Expert') return 2
if (label === 'Heavy') return 3
if (label === 'Grok 4.3') return 4
if (label === '5H') return 0
if (label === '') return 1
if (label === 'Spark5H') return 2
if (label === 'Spark') return 3
if (label === '剩余') return 4
if (label === '最低') return 5
if (label === '生图') return 6
return 10
if (label === '') return 5
if (label === '5H') return 6
if (label === '') return 7
if (label === 'Spark5H') return 8
if (label === 'Spark周') return 9
if (label === 'Prompt') return 10
if (label === 'Flex') return 11
if (label === '剩余') return 12
if (label === '最低') return 13
if (label === '生图') return 14
if (label === '速率') return 15
if (label === '模型') return 16
return 20
}
function clampPercent(value: number): number {
@@ -3998,6 +4010,57 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
}]
}
if (providerType === 'windsurf') {
const items: QuotaProgressItem[] = []
for (const [label, code] of [
['日', 'daily'],
['周', 'weekly'],
['Prompt', 'prompt'],
['Flex', 'flex'],
] as const) {
const window = getQuotaSnapshotWindow(quota, code)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) continue
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
: typeof window?.remaining_value === 'number' && typeof window?.limit_value === 'number'
? `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
: undefined
items.push({
label,
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
const rateLimitWindow = getQuotaSnapshotWindow(quota, 'rate_limit')
if (rateLimitWindow) {
items.push({
label: '速率',
remainingPercent: rateLimitWindow.is_exhausted ? 0 : 100,
resetAtSeconds: normalizeUnixSeconds(rateLimitWindow.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(rateLimitWindow.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
if (typeof quota.allowed_models_count === 'number' && Number.isFinite(quota.allowed_models_count)) {
items.push({
label: '模型',
remainingPercent: 100,
detail: `${quota.allowed_models_count} `,
resetAtSeconds: null,
resetSeconds: null,
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
return items
}
if (providerType === 'antigravity') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (windows.length === 0) return []