mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(provider): 原生接入 Windsurf provider
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ pub(crate) mod dispatch;
|
||||
pub(crate) mod grok;
|
||||
pub(crate) mod kiro;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod windsurf;
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user