mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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(
|
||||
) {
|
||||
|
||||
@@ -922,6 +922,317 @@ pub fn parse_kiro_usage_response(
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_user_status_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let user_status = value
|
||||
.get("userStatus")
|
||||
.or_else(|| value.get("user_status"))?;
|
||||
let plan_status = user_status
|
||||
.get("planStatus")
|
||||
.or_else(|| user_status.get("plan_status"))?;
|
||||
let plan_info = plan_status
|
||||
.get("planInfo")
|
||||
.or_else(|| plan_status.get("plan_info"));
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
|
||||
if let Some(plan_name) = plan_info
|
||||
.and_then(|value| {
|
||||
coerce_json_string(value.get("planName").or_else(|| value.get("plan_name")))
|
||||
})
|
||||
.or_else(|| {
|
||||
coerce_json_string(
|
||||
plan_status
|
||||
.get("planName")
|
||||
.or_else(|| plan_status.get("plan_name")),
|
||||
)
|
||||
})
|
||||
{
|
||||
result.insert("plan_name".to_string(), json!(plan_name));
|
||||
}
|
||||
if let Some(email) = coerce_json_string(user_status.get("email")) {
|
||||
result.insert("email".to_string(), json!(email));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("dailyQuotaRemainingPercent")
|
||||
.or_else(|| plan_status.get("daily_quota_remaining_percent"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("daily_remaining_percent".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("weeklyQuotaRemainingPercent")
|
||||
.or_else(|| plan_status.get("weekly_quota_remaining_percent"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("weekly_remaining_percent".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("dailyQuotaResetAtUnix")
|
||||
.or_else(|| plan_status.get("daily_quota_reset_at_unix"))
|
||||
.and_then(coerce_json_u64)
|
||||
{
|
||||
result.insert("daily_reset_at".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("weeklyQuotaResetAtUnix")
|
||||
.or_else(|| plan_status.get("weekly_quota_reset_at_unix"))
|
||||
.and_then(coerce_json_u64)
|
||||
{
|
||||
result.insert("weekly_reset_at".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("overageBalanceMicros")
|
||||
.or_else(|| plan_status.get("overage_balance_micros"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("overage_balance".to_string(), json!(value / 1_000_000.0));
|
||||
}
|
||||
|
||||
let legacy_credit =
|
||||
|value: Option<&serde_json::Value>| value.and_then(coerce_json_f64).map(|n| n / 100.0);
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("availablePromptCredits")
|
||||
.or_else(|| plan_status.get("available_prompt_credits")),
|
||||
) {
|
||||
result.insert("prompt_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("usedPromptCredits")
|
||||
.or_else(|| plan_status.get("used_prompt_credits")),
|
||||
) {
|
||||
result.insert("prompt_used".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
|
||||
plan_info
|
||||
.get("monthlyPromptCredits")
|
||||
.or_else(|| plan_info.get("monthly_prompt_credits"))
|
||||
})) {
|
||||
result.insert("prompt_limit".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("availableFlexCredits")
|
||||
.or_else(|| plan_status.get("available_flex_credits")),
|
||||
) {
|
||||
result.insert("flex_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("usedFlexCredits")
|
||||
.or_else(|| plan_status.get("used_flex_credits")),
|
||||
) {
|
||||
result.insert("flex_used".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
|
||||
plan_info
|
||||
.get("monthlyFlexCreditPurchaseAmount")
|
||||
.or_else(|| plan_info.get("monthly_flex_credit_purchase_amount"))
|
||||
})) {
|
||||
result.insert("flex_limit".to_string(), json!(value));
|
||||
}
|
||||
|
||||
let mut status_sources = vec![value, user_status, plan_status];
|
||||
if let Some(plan_info) = plan_info {
|
||||
status_sources.push(plan_info);
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"banned",
|
||||
&[
|
||||
"banned",
|
||||
"isBanned",
|
||||
"is_banned",
|
||||
"accountBanned",
|
||||
"account_banned",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"quarantined",
|
||||
&[
|
||||
"quarantined",
|
||||
"isQuarantined",
|
||||
"is_quarantined",
|
||||
"accountQuarantined",
|
||||
"account_quarantined",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"is_forbidden",
|
||||
&[
|
||||
"isForbidden",
|
||||
"is_forbidden",
|
||||
"forbidden",
|
||||
"accountForbidden",
|
||||
"account_forbidden",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| source.get(*alias).and_then(coerce_json_bool))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"ban_reason",
|
||||
&[
|
||||
"banReason",
|
||||
"ban_reason",
|
||||
"blockedReason",
|
||||
"blocked_reason",
|
||||
"reason",
|
||||
"message",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"quarantine_reason",
|
||||
&["quarantineReason", "quarantine_reason", "reason", "message"][..],
|
||||
),
|
||||
(
|
||||
"forbidden_reason",
|
||||
&["forbiddenReason", "forbidden_reason", "reason", "message"][..],
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| coerce_json_string(source.get(*alias)))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_model_configs_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let configs = value
|
||||
.get("clientModelConfigs")
|
||||
.or_else(|| value.get("client_model_configs"))
|
||||
.and_then(serde_json::Value::as_array)?;
|
||||
let mut models = Vec::new();
|
||||
for config in configs {
|
||||
let Some(model_uid) = coerce_json_string(
|
||||
config
|
||||
.get("modelUid")
|
||||
.or_else(|| config.get("model_uid"))
|
||||
.or_else(|| config.get("id"))
|
||||
.or_else(|| config.get("name")),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let mut model = serde_json::Map::new();
|
||||
model.insert("model_uid".to_string(), json!(model_uid));
|
||||
if let Some(label) = coerce_json_string(
|
||||
config
|
||||
.get("label")
|
||||
.or_else(|| config.get("displayName"))
|
||||
.or_else(|| config.get("display_name")),
|
||||
) {
|
||||
model.insert("label".to_string(), json!(label));
|
||||
}
|
||||
if let Some(provider) = coerce_json_string(config.get("provider")) {
|
||||
model.insert("provider".to_string(), json!(provider));
|
||||
}
|
||||
if let Some(value) = config
|
||||
.get("supportsImages")
|
||||
.or_else(|| config.get("supports_images"))
|
||||
.and_then(coerce_json_bool)
|
||||
{
|
||||
model.insert("supports_images".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = config
|
||||
.get("creditMultiplier")
|
||||
.or_else(|| config.get("credit_multiplier"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
model.insert("credit_multiplier".to_string(), json!(value));
|
||||
}
|
||||
models.push(serde_json::Value::Object(model));
|
||||
}
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
result.insert(
|
||||
"allowed_models_count".to_string(),
|
||||
json!(models.len() as u64),
|
||||
);
|
||||
result.insert("models".to_string(), serde_json::Value::Array(models));
|
||||
if let Some(default_model_uid) = value
|
||||
.get("defaultOverrideModelConfig")
|
||||
.or_else(|| value.get("default_override_model_config"))
|
||||
.and_then(|default_config| {
|
||||
coerce_json_string(
|
||||
default_config
|
||||
.get("modelUid")
|
||||
.or_else(|| default_config.get("model_uid")),
|
||||
)
|
||||
})
|
||||
{
|
||||
result.insert("default_model_uid".to_string(), json!(default_model_uid));
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_rate_limit_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let root = value.as_object()?;
|
||||
if root.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let has_capacity = value
|
||||
.get("hasCapacity")
|
||||
.or_else(|| value.get("has_capacity"))
|
||||
.and_then(coerce_json_bool)
|
||||
.unwrap_or(true);
|
||||
let messages_remaining = value
|
||||
.get("messagesRemaining")
|
||||
.or_else(|| value.get("messages_remaining"))
|
||||
.and_then(coerce_json_f64);
|
||||
let max_messages = value
|
||||
.get("maxMessages")
|
||||
.or_else(|| value.get("max_messages"))
|
||||
.and_then(coerce_json_f64);
|
||||
let retry_after_ms = value
|
||||
.get("retryAfterMs")
|
||||
.or_else(|| value.get("retry_after_ms"))
|
||||
.and_then(coerce_json_u64);
|
||||
|
||||
let limited = !has_capacity || messages_remaining.is_some_and(|value| value <= 0.0);
|
||||
let mut rate_limit = serde_json::Map::new();
|
||||
rate_limit.insert("limited".to_string(), json!(limited));
|
||||
rate_limit.insert("has_capacity".to_string(), json!(has_capacity));
|
||||
if let Some(value) = messages_remaining {
|
||||
rate_limit.insert("messages_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = max_messages {
|
||||
rate_limit.insert("max_messages".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = retry_after_ms {
|
||||
rate_limit.insert("retry_after_ms".to_string(), json!(value));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"updated_at": updated_at_unix_secs,
|
||||
"rate_limit": rate_limit,
|
||||
}))
|
||||
}
|
||||
|
||||
fn chatgpt_web_quota_feature_name(value: &serde_json::Value) -> Option<String> {
|
||||
coerce_json_string(
|
||||
value
|
||||
@@ -1141,8 +1452,10 @@ mod tests {
|
||||
use super::{
|
||||
codex_build_invalid_state, codex_runtime_invalid_reason,
|
||||
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
|
||||
parse_codex_wham_usage_response, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
|
||||
OAUTH_REFRESH_FAILED_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
|
||||
parse_codex_wham_usage_response, parse_windsurf_model_configs_response,
|
||||
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
@@ -1504,6 +1817,118 @@ mod tests {
|
||||
assert!(parsed.get("secondary_used_percent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_user_status_response() {
|
||||
let parsed = parse_windsurf_user_status_response(
|
||||
&json!({
|
||||
"userStatus": {
|
||||
"email": "windsurf@example.com",
|
||||
"isQuarantined": true,
|
||||
"quarantineReason": "quota review",
|
||||
"planStatus": {
|
||||
"dailyQuotaRemainingPercent": 45.5,
|
||||
"weeklyQuotaRemainingPercent": 80,
|
||||
"dailyQuotaResetAtUnix": "1775553285",
|
||||
"weeklyQuotaResetAtUnix": 1776158085u64,
|
||||
"availablePromptCredits": 900,
|
||||
"usedPromptCredits": 100,
|
||||
"availableFlexCredits": 250,
|
||||
"usedFlexCredits": 50,
|
||||
"overageBalanceMicros": 1250000,
|
||||
"planInfo": {
|
||||
"planName": "Pro",
|
||||
"monthlyPromptCredits": 1000,
|
||||
"monthlyFlexCreditPurchaseAmount": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
1_770_000_000,
|
||||
)
|
||||
.expect("windsurf user status should parse");
|
||||
|
||||
assert_eq!(parsed.get("plan_name"), Some(&json!("Pro")));
|
||||
assert_eq!(parsed.get("daily_remaining_percent"), Some(&json!(45.5)));
|
||||
assert_eq!(parsed.get("weekly_remaining_percent"), Some(&json!(80.0)));
|
||||
assert_eq!(parsed.get("daily_reset_at"), Some(&json!(1_775_553_285u64)));
|
||||
assert_eq!(
|
||||
parsed.get("weekly_reset_at"),
|
||||
Some(&json!(1_776_158_085u64))
|
||||
);
|
||||
assert_eq!(parsed.get("prompt_remaining"), Some(&json!(9.0)));
|
||||
assert_eq!(parsed.get("prompt_used"), Some(&json!(1.0)));
|
||||
assert_eq!(parsed.get("prompt_limit"), Some(&json!(10.0)));
|
||||
assert_eq!(parsed.get("flex_remaining"), Some(&json!(2.5)));
|
||||
assert_eq!(parsed.get("flex_used"), Some(&json!(0.5)));
|
||||
assert_eq!(parsed.get("flex_limit"), Some(&json!(3.0)));
|
||||
assert_eq!(parsed.get("overage_balance"), Some(&json!(1.25)));
|
||||
assert_eq!(parsed.get("email"), Some(&json!("windsurf@example.com")));
|
||||
assert_eq!(parsed.get("quarantined"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.get("quarantine_reason"),
|
||||
Some(&json!("quota review"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_000u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_model_configs_response() {
|
||||
let parsed = parse_windsurf_model_configs_response(
|
||||
&json!({
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"modelUid": "claude-sonnet-4-5",
|
||||
"label": "Claude Sonnet 4.5",
|
||||
"provider": "anthropic",
|
||||
"supportsImages": true,
|
||||
"creditMultiplier": 2
|
||||
},
|
||||
{
|
||||
"modelUid": "gpt-5-mini",
|
||||
"label": "GPT-5 mini"
|
||||
}
|
||||
],
|
||||
"defaultOverrideModelConfig": {
|
||||
"modelUid": "claude-sonnet-4-5"
|
||||
}
|
||||
}),
|
||||
1_770_000_100,
|
||||
)
|
||||
.expect("windsurf model configs should parse");
|
||||
|
||||
assert_eq!(parsed.get("allowed_models_count"), Some(&json!(2u64)));
|
||||
assert_eq!(
|
||||
parsed.get("default_model_uid"),
|
||||
Some(&json!("claude-sonnet-4-5"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_100u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_rate_limit_response() {
|
||||
let parsed = parse_windsurf_rate_limit_response(
|
||||
&json!({
|
||||
"hasCapacity": false,
|
||||
"messagesRemaining": 0,
|
||||
"maxMessages": 25,
|
||||
"retryAfterMs": 45000
|
||||
}),
|
||||
1_770_000_200,
|
||||
)
|
||||
.expect("windsurf rate limit should parse");
|
||||
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_200u64)));
|
||||
assert_eq!(parsed.pointer("/rate_limit/limited"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.pointer("/rate_limit/messages_remaining"),
|
||||
Some(&json!(0.0))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.pointer("/rate_limit/retry_after_ms"),
|
||||
Some(&json!(45000u64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_web_image_quota_from_conversation_init() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
|
||||
@@ -37,9 +37,27 @@ pub fn provider_oauth_pkce_s256(verifier: &str) -> String {
|
||||
|
||||
pub fn parse_provider_oauth_callback_params(callback_url: &str) -> BTreeMap<String, String> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let Ok(url) = Url::parse(callback_url.trim()) else {
|
||||
let raw_callback_url = callback_url.trim();
|
||||
let parsed_url = Url::parse(raw_callback_url).or_else(|_| {
|
||||
Url::parse(&format!(
|
||||
"https://aether.local/{}",
|
||||
raw_callback_url.trim_start_matches('/')
|
||||
))
|
||||
});
|
||||
let Ok(url) = parsed_url else {
|
||||
return merged;
|
||||
};
|
||||
if url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
&& raw_callback_url.contains('=')
|
||||
&& !raw_callback_url.contains("://")
|
||||
{
|
||||
for (key, value) in
|
||||
form_urlencoded::parse(raw_callback_url.trim_start_matches('?').as_bytes())
|
||||
{
|
||||
merged.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
}
|
||||
for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) {
|
||||
merged.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
@@ -377,6 +395,28 @@ mod tests {
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("nonce-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_relative_show_auth_token_url() {
|
||||
let params = parse_provider_oauth_callback_params(
|
||||
"show-auth-token?token=firebase-id-token&state=session-1&provider=google",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
params.get("token").map(String::as_str),
|
||||
Some("firebase-id-token")
|
||||
);
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-1"));
|
||||
assert_eq!(params.get("provider").map(String::as_str), Some("google"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_raw_query_string() {
|
||||
let params = parse_provider_oauth_callback_params("token=raw-token&state=session-raw");
|
||||
|
||||
assert_eq!(params.get("token").map(String::as_str), Some("raw-token"));
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-raw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_enrichment_extracts_identity_from_openai_claims() {
|
||||
let access_token = sample_unsigned_jwt(json!({
|
||||
|
||||
@@ -40,6 +40,7 @@ const AUTO_REMOVABLE_ACCOUNT_STATE_CODES: &[&str] = &[
|
||||
"account_banned",
|
||||
"account_suspended",
|
||||
"account_disabled",
|
||||
"account_quarantined",
|
||||
"workspace_deactivated",
|
||||
"account_forbidden",
|
||||
];
|
||||
@@ -275,7 +276,7 @@ fn resolve_from_metadata(
|
||||
upstream_metadata: Option<&Value>,
|
||||
) -> Option<PoolAccountState> {
|
||||
for source in metadata_sources(provider_type, upstream_metadata) {
|
||||
if json_bool(source.get("is_banned")) {
|
||||
if json_bool(source.get("is_banned")) || json_bool(source.get("banned")) {
|
||||
let reason = extract_reason(
|
||||
source,
|
||||
&["ban_reason", "forbidden_reason", "reason", "message"],
|
||||
@@ -290,6 +291,18 @@ fn resolve_from_metadata(
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
if json_bool(source.get("is_quarantined")) || json_bool(source.get("quarantined")) {
|
||||
let reason = extract_reason(source, &["quarantine_reason", "reason", "message"])
|
||||
.unwrap_or_else(|| "账号处于隔离状态".to_string());
|
||||
return Some(PoolAccountState {
|
||||
blocked: true,
|
||||
code: Some("account_quarantined".to_string()),
|
||||
label: Some("账号隔离".to_string()),
|
||||
reason: Some(reason),
|
||||
source: Some("metadata".to_string()),
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
if json_bool(source.get("is_forbidden")) || json_bool(source.get("account_disabled")) {
|
||||
let reason = extract_reason(
|
||||
source,
|
||||
@@ -574,6 +587,35 @@ mod tests {
|
||||
assert!(!should_auto_remove_account_state(&state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_windsurf_banned_and_quarantined_metadata_aliases() {
|
||||
let banned = resolve_pool_account_state(
|
||||
Some("windsurf"),
|
||||
Some(&json!({
|
||||
"windsurf": {
|
||||
"banned": true,
|
||||
"reason": "forbidden"
|
||||
}
|
||||
})),
|
||||
None,
|
||||
);
|
||||
assert!(banned.blocked);
|
||||
assert_eq!(banned.code.as_deref(), Some("account_banned"));
|
||||
|
||||
let quarantined = resolve_pool_account_state(
|
||||
Some("windsurf"),
|
||||
Some(&json!({
|
||||
"windsurf": {
|
||||
"quarantined": true
|
||||
}
|
||||
})),
|
||||
None,
|
||||
);
|
||||
assert!(quarantined.blocked);
|
||||
assert_eq!(quarantined.code.as_deref(), Some("account_quarantined"));
|
||||
assert!(should_auto_remove_account_state(&quarantined));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_snapshot_ignores_refresh_failed_as_account_block() {
|
||||
let snapshot = resolve_account_status_snapshot(
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
|
||||
use super::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
pub fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
|
||||
@@ -92,6 +92,7 @@ pub fn normalize_provider_private_response_value(
|
||||
data
|
||||
}
|
||||
}
|
||||
Some(WINDSURF_ENVELOPE_NAME) => normalize_windsurf_sync_response_value(data)?,
|
||||
_ => return None,
|
||||
};
|
||||
postprocess_private_response_value(&mut unwrapped, report_context);
|
||||
@@ -101,14 +102,28 @@ pub fn normalize_provider_private_response_value(
|
||||
pub fn transform_provider_private_stream_line(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
transform_provider_private_stream_line_with_event_state(report_context, line, &mut None)
|
||||
}
|
||||
|
||||
fn transform_provider_private_stream_line_with_event_state(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
current_event_type: &mut Option<String>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let Ok(text) = std::str::from_utf8(&line) else {
|
||||
return Ok(line);
|
||||
};
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some(event_name) = trimmed.strip_prefix("event:") {
|
||||
let event_name = event_name.trim().to_string();
|
||||
let is_error = event_name.eq_ignore_ascii_case("error");
|
||||
*current_event_type = (!event_name.is_empty()).then_some(event_name);
|
||||
return if is_error { Ok(line) } else { Ok(Vec::new()) };
|
||||
}
|
||||
let Some(data_line) = trimmed.strip_prefix("data:") else {
|
||||
return Ok(line);
|
||||
};
|
||||
@@ -121,6 +136,13 @@ pub fn transform_provider_private_stream_line(
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(line),
|
||||
};
|
||||
let event_is_error = current_event_type
|
||||
.as_deref()
|
||||
.is_some_and(|event| event.eq_ignore_ascii_case("error"));
|
||||
*current_event_type = None;
|
||||
if event_is_error {
|
||||
return Ok(line);
|
||||
}
|
||||
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
@@ -133,6 +155,9 @@ pub fn transform_provider_private_stream_line(
|
||||
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
|
||||
return Ok(line);
|
||||
}
|
||||
if envelope_name == WINDSURF_ENVELOPE_NAME && looks_like_windsurf_error(&body) {
|
||||
return Ok(line);
|
||||
}
|
||||
let unwrapped = match envelope_name {
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
|
||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
|
||||
@@ -147,6 +172,7 @@ pub fn transform_provider_private_stream_line(
|
||||
inject_antigravity_stream_tool_ids(&mut response);
|
||||
response
|
||||
}
|
||||
WINDSURF_ENVELOPE_NAME => normalize_windsurf_stream_event_value(&body).unwrap_or(body),
|
||||
_ => body,
|
||||
};
|
||||
|
||||
@@ -164,6 +190,7 @@ enum ProviderPrivateStreamNormalizeMode {
|
||||
pub struct ProviderPrivateStreamNormalizer<'a> {
|
||||
report_context: &'a Value,
|
||||
buffered: Vec<u8>,
|
||||
current_event_type: Option<String>,
|
||||
mode: ProviderPrivateStreamNormalizeMode,
|
||||
}
|
||||
|
||||
@@ -203,6 +230,7 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
|
||||
Some(ProviderPrivateStreamNormalizer {
|
||||
report_context,
|
||||
buffered: Vec::new(),
|
||||
current_event_type: None,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
@@ -219,8 +247,12 @@ impl ProviderPrivateStreamNormalizer<'_> {
|
||||
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
|
||||
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
|
||||
output.extend(
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)?,
|
||||
transform_provider_private_stream_line_with_event_state(
|
||||
self.report_context,
|
||||
line,
|
||||
&mut self.current_event_type,
|
||||
)
|
||||
.map_err(AiSurfaceFinalizeError::from)?,
|
||||
);
|
||||
}
|
||||
Ok(output)
|
||||
@@ -238,13 +270,171 @@ impl ProviderPrivateStreamNormalizer<'_> {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let line = std::mem::take(&mut self.buffered);
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
transform_provider_private_stream_line_with_event_state(
|
||||
self.report_context,
|
||||
line,
|
||||
&mut self.current_event_type,
|
||||
)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_windsurf_sync_response_value(data: Value) -> Option<Value> {
|
||||
if looks_like_openai_chat_response(&data) {
|
||||
return Some(data);
|
||||
}
|
||||
if looks_like_windsurf_error(&data) {
|
||||
return None;
|
||||
}
|
||||
if let Some(response) = data
|
||||
.get("response")
|
||||
.or_else(|| data.get("message"))
|
||||
.or_else(|| data.get("chatMessage"))
|
||||
.cloned()
|
||||
{
|
||||
if looks_like_openai_chat_response(&response) {
|
||||
return Some(response);
|
||||
}
|
||||
if let Some(text) = extract_windsurf_text(&response) {
|
||||
return Some(build_openai_chat_response_from_text(&data, text));
|
||||
}
|
||||
}
|
||||
extract_windsurf_text(&data).map(|text| build_openai_chat_response_from_text(&data, text))
|
||||
}
|
||||
|
||||
fn normalize_windsurf_stream_event_value(data: &Value) -> Option<Value> {
|
||||
if looks_like_openai_chat_stream_event(data) {
|
||||
return Some(data.clone());
|
||||
}
|
||||
if looks_like_windsurf_error(data) {
|
||||
return None;
|
||||
}
|
||||
let response = data
|
||||
.get("response")
|
||||
.or_else(|| data.get("message"))
|
||||
.or_else(|| data.get("chatMessage"))
|
||||
.unwrap_or(data);
|
||||
if looks_like_openai_chat_stream_event(response) {
|
||||
return Some(response.clone());
|
||||
}
|
||||
extract_windsurf_text(response).map(|text| {
|
||||
serde_json::json!({
|
||||
"id": windsurf_response_id(data),
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": text},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_openai_chat_response(value: &Value) -> bool {
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|choices| !choices.is_empty())
|
||||
}
|
||||
|
||||
fn looks_like_openai_chat_stream_event(value: &Value) -> bool {
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|choice| choice.contains_key("delta"))
|
||||
}
|
||||
|
||||
fn looks_like_windsurf_error(value: &Value) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if object.contains_key("error") {
|
||||
return true;
|
||||
}
|
||||
if object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if object.contains_key("code") || object.contains_key("status") {
|
||||
return object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
}
|
||||
object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& !object.contains_key("response")
|
||||
&& !object.contains_key("chatMessage")
|
||||
&& !object.contains_key("choices")
|
||||
&& !object.contains_key("text")
|
||||
&& !object.contains_key("content")
|
||||
&& !object.contains_key("assistantMessage")
|
||||
&& !object.contains_key("assistant_message")
|
||||
}
|
||||
|
||||
fn extract_windsurf_text(value: &Value) -> Option<String> {
|
||||
if let Some(text) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
let object = value.as_object()?;
|
||||
for key in [
|
||||
"text",
|
||||
"content",
|
||||
"message",
|
||||
"answer",
|
||||
"completion",
|
||||
"assistantMessage",
|
||||
"assistant_message",
|
||||
] {
|
||||
if let Some(text) = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn windsurf_response_id(value: &Value) -> String {
|
||||
value
|
||||
.get("id")
|
||||
.or_else(|| value.get("responseId"))
|
||||
.or_else(|| value.get("messageId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("windsurf-cascade")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_openai_chat_response_from_text(source: &Value, text: String) -> Value {
|
||||
serde_json::json!({
|
||||
"id": windsurf_response_id(source),
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
return false;
|
||||
@@ -497,6 +687,47 @@ mod tests {
|
||||
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_windsurf_sync_text_response_to_openai_chat() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let normalized = normalize_provider_private_response_value(
|
||||
json!({
|
||||
"responseId": "ws-1",
|
||||
"response": {"text": "hello from cascade"}
|
||||
}),
|
||||
&report_context,
|
||||
)
|
||||
.expect("windsurf response should normalize");
|
||||
|
||||
assert_eq!(normalized["id"], json!("ws-1"));
|
||||
assert_eq!(
|
||||
normalized["choices"][0]["message"]["content"],
|
||||
json!("hello from cascade")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_windsurf_stream_text_event() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let output = transform_provider_private_stream_line(
|
||||
&report_context,
|
||||
br#"data: {"responseId":"ws-2","response":{"text":"chunk"}}"#.to_vec(),
|
||||
)
|
||||
.expect("windsurf stream line should transform");
|
||||
let text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(text.contains(r#""object":"chat.completion.chunk""#));
|
||||
assert!(text.contains(r#""content":"chunk""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_stream_normalizer_unwraps_antigravity_stream() {
|
||||
let report_context = json!({
|
||||
@@ -526,4 +757,39 @@ data: {"message":"bad"}
|
||||
"#;
|
||||
assert!(stream_body_contains_error_event(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_sync_error_message_is_not_normalized_as_success() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
|
||||
let normalized = normalize_provider_private_response_value(
|
||||
json!({"message": "rate limited"}),
|
||||
&report_context,
|
||||
);
|
||||
|
||||
assert!(normalized.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_stream_error_event_is_preserved() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
|
||||
.expect("normalizer should exist");
|
||||
let output = normalizer
|
||||
.push_chunk(b"event: error\ndata: {\"message\":\"rate limited\"}\n\n")
|
||||
.expect("normalizer should preserve error event");
|
||||
let output_text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(output_text.contains("event: error"));
|
||||
assert!(output_text.contains("\"message\":\"rate limited\""));
|
||||
assert!(!output_text.contains("chat.completion.chunk"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
|
||||
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
|
||||
pub const WINDSURF_PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
|
||||
pub const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
pub const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdaptationSurface {
|
||||
@@ -10,6 +12,7 @@ pub enum ProviderAdaptationSurface {
|
||||
AntigravityGeminiCli,
|
||||
GeminiCliV1Internal,
|
||||
KiroClaudeCli,
|
||||
WindsurfCascade,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -70,6 +73,17 @@ const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
|
||||
requires_eventstream_accept: true,
|
||||
unwraps_response_envelope: false,
|
||||
},
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::WindsurfCascade,
|
||||
provider_type: Some(WINDSURF_PROVIDER_TYPE),
|
||||
envelope_name: WINDSURF_ENVELOPE_NAME,
|
||||
anchor_api_format: "openai:chat",
|
||||
supports_request_bridge: true,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: false,
|
||||
unwraps_response_envelope: true,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn provider_adaptation_descriptor_for_envelope(
|
||||
@@ -141,7 +155,7 @@ mod tests {
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -164,6 +178,10 @@ mod tests {
|
||||
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:messages"),
|
||||
Some("claude:messages")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_adaptation_anchor_api_format(WINDSURF_ENVELOPE_NAME, "openai:chat"),
|
||||
Some("openai:chat")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,5 +202,9 @@ mod tests {
|
||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(provider_adaptation_should_unwrap_stream_envelope(
|
||||
WINDSURF_ENVELOPE_NAME,
|
||||
"openai:chat"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,10 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
|
||||
&& api_format == "openai:chat"
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key"
|
||||
&& matches!(
|
||||
@@ -557,6 +561,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_windsurf_managed_keys_for_openai_chat_only() {
|
||||
let mut oauth = sample_row("windsurf-oauth", "openai:chat", "gpt-5", 10);
|
||||
oauth.provider_type = "windsurf".to_string();
|
||||
oauth.key_auth_type = "oauth".to_string();
|
||||
let mut api_key = sample_row("windsurf-api-key", "openai:chat", "gpt-5", 20);
|
||||
api_key.provider_type = "windsurf".to_string();
|
||||
api_key.key_auth_type = "api_key".to_string();
|
||||
let mut responses = sample_row("windsurf-responses", "openai:responses", "gpt-5", 30);
|
||||
responses.provider_type = "windsurf".to_string();
|
||||
responses.key_auth_type = "oauth".to_string();
|
||||
|
||||
let repository =
|
||||
InMemoryMinimalCandidateSelectionReadRepository::seed(vec![oauth, api_key, responses]);
|
||||
|
||||
let rows = repository
|
||||
.list_for_exact_api_format_and_requested_model("openai:chat", "gpt-5")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.map(|row| row.provider_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["windsurf-oauth", "windsurf-api-key"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_exact_api_format_only() {
|
||||
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
|
||||
@@ -452,6 +452,10 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
|
||||
&& api_format == "openai:chat"
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key"
|
||||
&& matches!(
|
||||
|
||||
@@ -113,6 +113,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($3) = 'gemini:generate_content'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'windsurf'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
|
||||
AND LOWER($3) = 'openai:chat'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
|
||||
AND (
|
||||
@@ -135,7 +140,8 @@ WHERE p.is_active = TRUE
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
'kiro',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
|
||||
)
|
||||
@@ -302,6 +308,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($4) = 'gemini:generate_content'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'windsurf'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
|
||||
AND LOWER($4) = 'openai:chat'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
|
||||
AND (
|
||||
@@ -324,7 +335,8 @@ WHERE p.is_active = TRUE
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
'kiro',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
|
||||
)
|
||||
@@ -490,6 +502,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($6) = 'gemini:generate_content'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'windsurf'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
|
||||
AND LOWER($6) = 'openai:chat'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
|
||||
AND (
|
||||
@@ -512,7 +529,8 @@ WHERE p.is_active = TRUE
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
'kiro',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
|
||||
)
|
||||
@@ -1322,6 +1340,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_selection_sql_allows_windsurf_openai_chat_managed_keys() {
|
||||
let requested_model_sql = requested_model_selection_sql();
|
||||
for sql in [
|
||||
LIST_FOR_EXACT_API_FORMAT_SQL,
|
||||
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
|
||||
LIST_POOL_KEYS_FOR_GROUP_SQL,
|
||||
requested_model_sql.as_str(),
|
||||
] {
|
||||
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'windsurf'"));
|
||||
assert!(
|
||||
sql.contains("LOWER($3) = 'openai:chat'")
|
||||
|| sql.contains("LOWER($4) = 'openai:chat'")
|
||||
|| sql.contains("LOWER($6) = 'openai:chat'")
|
||||
);
|
||||
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')"));
|
||||
assert!(sql.contains("'windsurf'"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_selection_sql_allows_vertex_embedding_auth() {
|
||||
let requested_model_sql = requested_model_selection_sql();
|
||||
|
||||
@@ -831,6 +831,10 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
|
||||
&& api_format == "openai:chat"
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key"
|
||||
&& matches!(
|
||||
|
||||
@@ -2,6 +2,7 @@ mod antigravity;
|
||||
mod codex;
|
||||
mod generic;
|
||||
mod kiro;
|
||||
mod windsurf;
|
||||
|
||||
pub use antigravity::AntigravityProviderOAuthAdapter;
|
||||
pub use codex::CodexProviderOAuthAdapter;
|
||||
@@ -13,3 +14,7 @@ pub use kiro::{
|
||||
DEFAULT_KIRO_VERSION, DEFAULT_NODE_VERSION, DEFAULT_REGION, DEFAULT_SYSTEM_VERSION,
|
||||
KIRO_PROVIDER_TYPE,
|
||||
};
|
||||
pub use windsurf::{
|
||||
WindsurfProviderOAuthAdapter, WINDSURF_CLIENT_ID, WINDSURF_PROVIDER_TYPE,
|
||||
WINDSURF_SHOW_AUTH_TOKEN_REDIRECT, WINDSURF_SIGNIN_URL,
|
||||
};
|
||||
|
||||
982
crates/aether-oauth/src/provider/providers/windsurf.rs
Normal file
982
crates/aether-oauth/src/provider/providers/windsurf.rs
Normal file
@@ -0,0 +1,982 @@
|
||||
use crate::core::{current_unix_secs, OAuthAuthorizeResponse, OAuthError, OAuthTokenSet};
|
||||
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest};
|
||||
use crate::provider::{
|
||||
ProviderOAuthAccount, ProviderOAuthAccountState, ProviderOAuthAdapter,
|
||||
ProviderOAuthCapabilities, ProviderOAuthImportInput, ProviderOAuthProbeResult,
|
||||
ProviderOAuthRequestAuth, ProviderOAuthTokenSet, ProviderOAuthTransportContext,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const WINDSURF_PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const WINDSURF_SIGNIN_URL: &str = "https://windsurf.com/windsurf/signin";
|
||||
pub const WINDSURF_CLIENT_ID: &str = "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u";
|
||||
pub const WINDSURF_SHOW_AUTH_TOKEN_REDIRECT: &str = "show-auth-token";
|
||||
const AUTH1_PASSWORD_LOGIN_URL: &str = "https://windsurf.com/_devin-auth/password/login";
|
||||
const WINDSURF_POST_AUTH_URL: &str =
|
||||
"https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth";
|
||||
const WINDSURF_POST_AUTH_LEGACY_URL: &str =
|
||||
"https://server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth";
|
||||
const WINDSURF_REGISTER_USER_URL: &str =
|
||||
"https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser";
|
||||
const WINDSURF_REGISTER_USER_LEGACY_URL: &str = "https://api.codeium.com/register_user/";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WindsurfProviderOAuthAdapter;
|
||||
|
||||
impl WindsurfProviderOAuthAdapter {
|
||||
async fn import_raw_api_key(
|
||||
&self,
|
||||
input: &ProviderOAuthImportInput,
|
||||
api_key: &str,
|
||||
auth_method: &str,
|
||||
source: &str,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return Err(OAuthError::invalid_request("windsurf api_key is required"));
|
||||
}
|
||||
|
||||
let mut auth_config = Map::new();
|
||||
auth_config.insert("provider_type".to_string(), json!(WINDSURF_PROVIDER_TYPE));
|
||||
auth_config.insert("auth_method".to_string(), json!(auth_method));
|
||||
auth_config.insert("register_source".to_string(), json!(source));
|
||||
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
|
||||
if let Some(name) = input.name.as_deref().and_then(non_empty_str) {
|
||||
auth_config.insert("name".to_string(), json!(name));
|
||||
}
|
||||
if let Some(raw) = input.raw_credentials.as_ref() {
|
||||
copy_optional_string(raw, &mut auth_config, "email", &["email"]);
|
||||
copy_optional_string(
|
||||
raw,
|
||||
&mut auth_config,
|
||||
"social_provider",
|
||||
&["social_provider", "socialProvider"],
|
||||
);
|
||||
}
|
||||
if auth_config.get("email").is_some() {
|
||||
auth_config.insert("email_verified".to_string(), json!(false));
|
||||
}
|
||||
insert_secret_fingerprint(&mut auth_config, "credential_fingerprint", api_key);
|
||||
|
||||
Ok(provider_token_set(
|
||||
api_key,
|
||||
Value::Object(auth_config),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn register_with_token(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
input: &ProviderOAuthImportInput,
|
||||
token: &str,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(OAuthError::invalid_request("windsurf token is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for (url, source) in [
|
||||
(WINDSURF_REGISTER_USER_URL, "new"),
|
||||
(WINDSURF_REGISTER_USER_LEGACY_URL, "legacy"),
|
||||
] {
|
||||
let response = executor
|
||||
.execute(OAuthHttpRequest {
|
||||
request_id: format!("provider-oauth:windsurf-register:{source}"),
|
||||
method: reqwest::Method::POST,
|
||||
url: url.to_string(),
|
||||
headers: json_connect_headers(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(json!({ "firebase_id_token": token })),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
})
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) if (200..300).contains(&response.status_code) => {
|
||||
let payload = response
|
||||
.json_body
|
||||
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("RegisterUser response is not json")
|
||||
})?;
|
||||
if let Some(api_key) = string_any(&payload, &["api_key", "apiKey"]) {
|
||||
let mut auth_config = Map::new();
|
||||
auth_config
|
||||
.insert("provider_type".to_string(), json!(WINDSURF_PROVIDER_TYPE));
|
||||
auth_config.insert("auth_method".to_string(), json!("token"));
|
||||
auth_config.insert("register_source".to_string(), json!(source));
|
||||
insert_secret_fingerprint(&mut auth_config, "id_token_fingerprint", token);
|
||||
insert_secret_fingerprint(
|
||||
&mut auth_config,
|
||||
"credential_fingerprint",
|
||||
&api_key,
|
||||
);
|
||||
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
|
||||
copy_optional_string(&payload, &mut auth_config, "name", &["name"]);
|
||||
let payload_email_verified = string_any(&payload, &["email"]).is_some();
|
||||
copy_optional_string(&payload, &mut auth_config, "email", &["email"]);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"account_id",
|
||||
&["account_id", "accountId", "user_id", "userId"],
|
||||
);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"primary_org_id",
|
||||
&[
|
||||
"primary_org_id",
|
||||
"primaryOrgId",
|
||||
"organization_id",
|
||||
"organizationId",
|
||||
],
|
||||
);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"api_server_url",
|
||||
&["api_server_url", "apiServerUrl"],
|
||||
);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"plan_name",
|
||||
&["plan_name", "planName", "plan"],
|
||||
);
|
||||
if let Some(name) = input.name.as_deref().and_then(non_empty_str) {
|
||||
auth_config
|
||||
.entry("name".to_string())
|
||||
.or_insert_with(|| json!(name));
|
||||
}
|
||||
if let Some(raw) = input.raw_credentials.as_ref() {
|
||||
copy_optional_string(raw, &mut auth_config, "email", &["email"]);
|
||||
copy_optional_string(
|
||||
raw,
|
||||
&mut auth_config,
|
||||
"social_provider",
|
||||
&["social_provider", "socialProvider"],
|
||||
);
|
||||
}
|
||||
if auth_config.get("email").is_some() {
|
||||
auth_config.insert(
|
||||
"email_verified".to_string(),
|
||||
json!(payload_email_verified),
|
||||
);
|
||||
}
|
||||
return Ok(provider_token_set(
|
||||
&api_key,
|
||||
Value::Object(auth_config),
|
||||
None,
|
||||
));
|
||||
}
|
||||
errors.push(format!("{source}=missing api_key"));
|
||||
}
|
||||
Ok(response) => errors.push(format!(
|
||||
"{source}=HTTP {} {}",
|
||||
response.status_code,
|
||||
truncate_body(&response.body_text)
|
||||
)),
|
||||
Err(error) => errors.push(format!("{source}={error}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err(OAuthError::invalid_response(format!(
|
||||
"RegisterUser failed: {}",
|
||||
errors.join(" | ")
|
||||
)))
|
||||
}
|
||||
|
||||
async fn login_with_password(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
input: &ProviderOAuthImportInput,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
let email = email.trim();
|
||||
let password = password.trim();
|
||||
if email.is_empty() || password.is_empty() {
|
||||
return Err(OAuthError::invalid_request(
|
||||
"windsurf email and password are required",
|
||||
));
|
||||
}
|
||||
|
||||
let login_response = executor
|
||||
.execute(OAuthHttpRequest {
|
||||
request_id: "provider-oauth:windsurf-auth1-login".to_string(),
|
||||
method: reqwest::Method::POST,
|
||||
url: AUTH1_PASSWORD_LOGIN_URL.to_string(),
|
||||
headers: json_headers(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(json!({ "email": email, "password": password })),
|
||||
body_bytes: None,
|
||||
network: ctx.network.clone(),
|
||||
})
|
||||
.await?;
|
||||
if !(200..300).contains(&login_response.status_code) {
|
||||
return Err(OAuthError::HttpStatus {
|
||||
status_code: login_response.status_code,
|
||||
body_excerpt: truncate_body(&login_response.body_text),
|
||||
});
|
||||
}
|
||||
let login_payload = login_response
|
||||
.json_body
|
||||
.or_else(|| serde_json::from_str::<Value>(&login_response.body_text).ok())
|
||||
.ok_or_else(|| OAuthError::invalid_response("Auth1 response is not json"))?;
|
||||
let auth1_token = string_any(&login_payload, &["token", "access_token", "accessToken"])
|
||||
.ok_or_else(|| OAuthError::invalid_response("Auth1 response missing token"))?;
|
||||
|
||||
let mut post_auth_errors = Vec::new();
|
||||
for (url, source) in [
|
||||
(WINDSURF_POST_AUTH_URL, "new"),
|
||||
(WINDSURF_POST_AUTH_LEGACY_URL, "legacy"),
|
||||
] {
|
||||
let mut headers = proto_headers();
|
||||
headers.insert("x-devin-auth1-token".to_string(), auth1_token.clone());
|
||||
let response = executor
|
||||
.execute(OAuthHttpRequest {
|
||||
request_id: format!("provider-oauth:windsurf-post-auth:{source}"),
|
||||
method: reqwest::Method::POST,
|
||||
url: url.to_string(),
|
||||
headers,
|
||||
content_type: Some("application/proto".to_string()),
|
||||
json_body: None,
|
||||
body_bytes: Some(Vec::new()),
|
||||
network: ctx.network.clone(),
|
||||
})
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) if (200..300).contains(&response.status_code) => {
|
||||
let payload = response
|
||||
.json_body
|
||||
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("WindsurfPostAuth response is not json")
|
||||
})?;
|
||||
if let Some(session_token) = string_any(&payload, &["sessionToken"]) {
|
||||
let mut auth_config = Map::new();
|
||||
auth_config
|
||||
.insert("provider_type".to_string(), json!(WINDSURF_PROVIDER_TYPE));
|
||||
auth_config.insert("auth_method".to_string(), json!("email_password"));
|
||||
auth_config.insert("register_source".to_string(), json!(source));
|
||||
auth_config.insert("email".to_string(), json!(email));
|
||||
auth_config.insert("email_verified".to_string(), json!(true));
|
||||
auth_config.insert("updated_at".to_string(), json!(current_unix_secs()));
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"account_id",
|
||||
&["accountId", "account_id"],
|
||||
);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"primary_org_id",
|
||||
&["primaryOrgId", "primary_org_id"],
|
||||
);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"api_server_url",
|
||||
&["apiServerUrl", "api_server_url"],
|
||||
);
|
||||
copy_optional_string(
|
||||
&payload,
|
||||
&mut auth_config,
|
||||
"plan_name",
|
||||
&["planName", "plan_name", "plan"],
|
||||
);
|
||||
if let Some(name) = input.name.as_deref().and_then(non_empty_str) {
|
||||
auth_config.insert("name".to_string(), json!(name));
|
||||
}
|
||||
insert_secret_fingerprint(
|
||||
&mut auth_config,
|
||||
"credential_fingerprint",
|
||||
&session_token,
|
||||
);
|
||||
return Ok(provider_token_set(
|
||||
&session_token,
|
||||
Value::Object(auth_config),
|
||||
None,
|
||||
));
|
||||
}
|
||||
post_auth_errors.push(format!("{source}=missing sessionToken"));
|
||||
}
|
||||
Ok(response) => post_auth_errors.push(format!(
|
||||
"{source}=HTTP {} {}",
|
||||
response.status_code,
|
||||
truncate_body(&response.body_text)
|
||||
)),
|
||||
Err(error) => post_auth_errors.push(format!("{source}={error}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err(OAuthError::invalid_response(format!(
|
||||
"WindsurfPostAuth failed: {}",
|
||||
post_auth_errors.join(" | ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderOAuthAdapter for WindsurfProviderOAuthAdapter {
|
||||
fn provider_type(&self) -> &'static str {
|
||||
WINDSURF_PROVIDER_TYPE
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderOAuthCapabilities {
|
||||
ProviderOAuthCapabilities {
|
||||
supports_authorization_code: false,
|
||||
supports_refresh_token_import: true,
|
||||
supports_batch_import: true,
|
||||
supports_device_flow: true,
|
||||
supports_account_probe: true,
|
||||
rotates_refresh_token: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_authorize_url(
|
||||
&self,
|
||||
_ctx: &ProviderOAuthTransportContext,
|
||||
state: &str,
|
||||
_code_challenge: Option<&str>,
|
||||
) -> Result<OAuthAuthorizeResponse, OAuthError> {
|
||||
let mut url = url::Url::parse(WINDSURF_SIGNIN_URL)
|
||||
.map_err(|_| OAuthError::invalid_response("invalid windsurf signin url"))?;
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("response_type", "token");
|
||||
query.append_pair("client_id", WINDSURF_CLIENT_ID);
|
||||
query.append_pair("redirect_uri", WINDSURF_SHOW_AUTH_TOKEN_REDIRECT);
|
||||
query.append_pair("state", state);
|
||||
query.append_pair("prompt", "login");
|
||||
query.append_pair("redirect_parameters_type", "query");
|
||||
query.append_pair("workflow", "");
|
||||
}
|
||||
Ok(OAuthAuthorizeResponse {
|
||||
authorize_url: url.to_string(),
|
||||
state: state.to_string(),
|
||||
code_challenge: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn import_credentials(
|
||||
&self,
|
||||
executor: &dyn OAuthHttpExecutor,
|
||||
ctx: &ProviderOAuthTransportContext,
|
||||
input: ProviderOAuthImportInput,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
let raw = input.raw_credentials.as_ref();
|
||||
if let Some(api_key) = raw.and_then(|value| string_any(value, &["api_key", "apiKey"])) {
|
||||
return self
|
||||
.import_raw_api_key(&input, &api_key, "api_key", "manual")
|
||||
.await;
|
||||
}
|
||||
if let Some(api_key) = input
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.and_then(|value| windsurf_raw_api_key(value).map(ToOwned::to_owned))
|
||||
{
|
||||
return self
|
||||
.import_raw_api_key(&input, &api_key, "api_key", "manual")
|
||||
.await;
|
||||
}
|
||||
if let Some(token) = raw.and_then(|value| {
|
||||
string_any(
|
||||
value,
|
||||
&[
|
||||
"token",
|
||||
"auth_token",
|
||||
"authToken",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"refresh_token",
|
||||
"refreshToken",
|
||||
],
|
||||
)
|
||||
}) {
|
||||
if windsurf_raw_api_key(&token).is_some() {
|
||||
return self
|
||||
.import_raw_api_key(&input, &token, "api_key", "manual")
|
||||
.await;
|
||||
}
|
||||
return self
|
||||
.register_with_token(executor, ctx, &input, &token)
|
||||
.await;
|
||||
}
|
||||
if let Some(token) = input.refresh_token.as_deref().and_then(non_empty_str) {
|
||||
return self.register_with_token(executor, ctx, &input, token).await;
|
||||
}
|
||||
if let (Some(email), Some(password)) = (
|
||||
raw.and_then(|value| string_any(value, &["email"])),
|
||||
raw.and_then(|value| string_any(value, &["password"])),
|
||||
) {
|
||||
return self
|
||||
.login_with_password(executor, ctx, &input, &email, &password)
|
||||
.await;
|
||||
}
|
||||
|
||||
Err(OAuthError::invalid_request(
|
||||
"windsurf credentials require api_key, token, or email/password",
|
||||
))
|
||||
}
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
_executor: &dyn OAuthHttpExecutor,
|
||||
_ctx: &ProviderOAuthTransportContext,
|
||||
account: &ProviderOAuthAccount,
|
||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||
Ok(provider_token_set(
|
||||
&account.access_token,
|
||||
account.auth_config.clone(),
|
||||
account.expires_at_unix_secs,
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_request_auth(
|
||||
&self,
|
||||
account: &ProviderOAuthAccount,
|
||||
) -> Result<ProviderOAuthRequestAuth, OAuthError> {
|
||||
Ok(ProviderOAuthRequestAuth::Header {
|
||||
name: "authorization".to_string(),
|
||||
value: format!("Bearer {}", account.access_token.trim()),
|
||||
})
|
||||
}
|
||||
|
||||
fn account_fingerprint(&self, account: &ProviderOAuthAccount) -> Option<String> {
|
||||
Some(secret_fingerprint(&account.access_token))
|
||||
}
|
||||
|
||||
async fn probe_account_state(
|
||||
&self,
|
||||
_executor: &dyn OAuthHttpExecutor,
|
||||
_ctx: &ProviderOAuthTransportContext,
|
||||
account: &ProviderOAuthAccount,
|
||||
) -> Result<Option<ProviderOAuthProbeResult>, OAuthError> {
|
||||
let metadata = account
|
||||
.identity
|
||||
.get(WINDSURF_PROVIDER_TYPE)
|
||||
.cloned()
|
||||
.or_else(|| account.auth_config.get(WINDSURF_PROVIDER_TYPE).cloned());
|
||||
let email = string_any(&account.auth_config, &["email"])
|
||||
.or_else(|| {
|
||||
account
|
||||
.identity
|
||||
.get("email")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|value| string_any(value, &["email"]))
|
||||
});
|
||||
let invalid_reason = string_any(
|
||||
&account.auth_config,
|
||||
&["oauth_invalid_reason", "invalid_reason"],
|
||||
)
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|value| string_any(value, &["last_error", "invalid_reason"]))
|
||||
});
|
||||
Ok(Some(ProviderOAuthProbeResult {
|
||||
state: ProviderOAuthAccountState {
|
||||
is_valid: !account.access_token.trim().is_empty() && invalid_reason.is_none(),
|
||||
email,
|
||||
quota: metadata,
|
||||
invalid_reason,
|
||||
raw: Some(json!({
|
||||
"auth_config": account.auth_config,
|
||||
"identity": account.identity,
|
||||
})),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_token_set(
|
||||
api_key: &str,
|
||||
auth_config: Value,
|
||||
expires_at_unix_secs: Option<u64>,
|
||||
) -> ProviderOAuthTokenSet {
|
||||
ProviderOAuthTokenSet {
|
||||
token_set: OAuthTokenSet {
|
||||
access_token: api_key.trim().to_string(),
|
||||
refresh_token: None,
|
||||
token_type: Some("windsurf_api_key".to_string()),
|
||||
scope: None,
|
||||
expires_at_unix_secs,
|
||||
raw_payload: Some(json!({
|
||||
"access_token": api_key.trim(),
|
||||
"token_type": "windsurf_api_key",
|
||||
})),
|
||||
},
|
||||
auth_config,
|
||||
}
|
||||
}
|
||||
|
||||
fn windsurf_raw_api_key(value: &str) -> Option<&str> {
|
||||
let value = value.trim();
|
||||
if value.starts_with("devin-session-token$") || value.starts_with("sk-") {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn json_headers() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
("user-agent".to_string(), "windsurf/1.9600.41".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn json_connect_headers() -> BTreeMap<String, String> {
|
||||
let mut headers = json_headers();
|
||||
headers.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
headers
|
||||
}
|
||||
|
||||
fn proto_headers() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("content-type".to_string(), "application/proto".to_string()),
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
("connect-protocol-version".to_string(), "1".to_string()),
|
||||
(
|
||||
"referer".to_string(),
|
||||
"https://windsurf.com/account/login".to_string(),
|
||||
),
|
||||
("user-agent".to_string(), "windsurf/1.9600.41".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn copy_optional_string(
|
||||
value: &Value,
|
||||
target: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
aliases: &[&str],
|
||||
) {
|
||||
if let Some(text) = string_any(value, aliases) {
|
||||
target.entry(key.to_string()).or_insert_with(|| json!(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn string_any(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
value
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(non_empty_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_secret_fingerprint(target: &mut Map<String, Value>, key: &str, secret: &str) {
|
||||
let secret = secret.trim();
|
||||
if !secret.is_empty() {
|
||||
target.insert(key.to_string(), json!(secret_fingerprint(secret)));
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_str(value: &str) -> Option<&str> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn truncate_body(body: &str) -> String {
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
if let Ok(mut value) = serde_json::from_str::<Value>(body) {
|
||||
redact_sensitive_json(&mut value);
|
||||
return value.to_string().chars().take(500).collect();
|
||||
}
|
||||
if contains_sensitive_marker(body) {
|
||||
"[REDACTED upstream error body]".to_string()
|
||||
} else {
|
||||
body.chars().take(500).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_sensitive_json(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if is_sensitive_key(key) {
|
||||
*value = json!("[REDACTED]");
|
||||
} else {
|
||||
redact_sensitive_json(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
redact_sensitive_json(item);
|
||||
}
|
||||
}
|
||||
Value::String(text) if looks_like_sensitive_secret(text) => {
|
||||
*text = "[REDACTED]".to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
let normalized = key
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
normalized.contains("token")
|
||||
|| normalized.contains("apikey")
|
||||
|| normalized.contains("password")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.contains("secret")
|
||||
}
|
||||
|
||||
fn looks_like_sensitive_secret(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
value.starts_with("devin-session-token$")
|
||||
|| value.starts_with("sk-")
|
||||
|| (value.len() > 80 && value.split('.').count() == 3)
|
||||
}
|
||||
|
||||
fn contains_sensitive_marker(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
[
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"password",
|
||||
"authorization",
|
||||
"sessiontoken",
|
||||
"firebase_id_token",
|
||||
"idtoken",
|
||||
"secret",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| value.contains(marker))
|
||||
}
|
||||
|
||||
fn secret_fingerprint(value: &str) -> String {
|
||||
let digest = Sha256::digest(value.as_bytes());
|
||||
let mut fingerprint = String::with_capacity(16);
|
||||
for byte in digest.iter().take(8) {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut fingerprint, "{byte:02x}");
|
||||
}
|
||||
fingerprint
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
secret_fingerprint, truncate_body, WindsurfProviderOAuthAdapter, AUTH1_PASSWORD_LOGIN_URL,
|
||||
WINDSURF_POST_AUTH_URL, WINDSURF_REGISTER_USER_URL,
|
||||
};
|
||||
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
||||
use crate::provider::{
|
||||
ProviderOAuthAdapter, ProviderOAuthImportInput, ProviderOAuthTransportContext,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingExecutor {
|
||||
requests: Arc<Mutex<Vec<OAuthHttpRequest>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OAuthHttpExecutor for RecordingExecutor {
|
||||
async fn execute(
|
||||
&self,
|
||||
request: OAuthHttpRequest,
|
||||
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("requests lock")
|
||||
.push(request.clone());
|
||||
if request.url == WINDSURF_REGISTER_USER_URL {
|
||||
return Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: r#"{"apiKey":"sk-ws-01-registered","name":"Alice","email":"alice@example.com","accountId":"acct-1","primaryOrgId":"org-1","planName":"Pro","apiServerUrl":"https://server.codeium.com"}"#.to_string(),
|
||||
json_body: Some(json!({
|
||||
"apiKey": "sk-ws-01-registered",
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com",
|
||||
"accountId": "acct-1",
|
||||
"primaryOrgId": "org-1",
|
||||
"planName": "Pro",
|
||||
"apiServerUrl": "https://server.codeium.com"
|
||||
})),
|
||||
});
|
||||
}
|
||||
if request.url == AUTH1_PASSWORD_LOGIN_URL {
|
||||
return Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: r#"{"token":"auth1-token"}"#.to_string(),
|
||||
json_body: Some(json!({"token": "auth1-token"})),
|
||||
});
|
||||
}
|
||||
if request.url == WINDSURF_POST_AUTH_URL {
|
||||
return Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: r#"{"sessionToken":"devin-session-token$password","accountId":"acct-password","primaryOrgId":"org-password","planName":"Pro"}"#.to_string(),
|
||||
json_body: Some(json!({
|
||||
"sessionToken": "devin-session-token$password",
|
||||
"accountId": "acct-password",
|
||||
"primaryOrgId": "org-password",
|
||||
"planName": "Pro"
|
||||
})),
|
||||
});
|
||||
}
|
||||
Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: "{}".to_string(),
|
||||
json_body: Some(json!({})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx() -> ProviderOAuthTransportContext {
|
||||
ProviderOAuthTransportContext {
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
provider_type: "windsurf".to_string(),
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
auth_type: Some("oauth".to_string()),
|
||||
decrypted_api_key: None,
|
||||
decrypted_auth_config: None,
|
||||
provider_config: None,
|
||||
endpoint_config: None,
|
||||
key_config: None,
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_raw_api_key_without_network() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = WindsurfProviderOAuthAdapter;
|
||||
let result = adapter
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx(),
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: "windsurf".to_string(),
|
||||
name: Some("Alice".to_string()),
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(json!({
|
||||
"api_key": "devin-session-token$abc",
|
||||
"email": "alice@example.com"
|
||||
})),
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("api key should import");
|
||||
|
||||
assert_eq!(result.token_set.access_token, "devin-session-token$abc");
|
||||
assert_eq!(result.auth_config["auth_method"], json!("api_key"));
|
||||
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
|
||||
assert_eq!(result.auth_config["email_verified"], json!(false));
|
||||
assert_eq!(
|
||||
result.auth_config["credential_fingerprint"],
|
||||
json!(secret_fingerprint("devin-session-token$abc"))
|
||||
);
|
||||
assert!(executor.requests.lock().expect("requests lock").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchanges_show_auth_token_with_register_user() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = WindsurfProviderOAuthAdapter;
|
||||
let result = adapter
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx(),
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: "windsurf".to_string(),
|
||||
name: None,
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(json!({
|
||||
"token": "firebase-id-token",
|
||||
"email": "alice@example.com"
|
||||
})),
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("token should register");
|
||||
|
||||
assert_eq!(result.token_set.access_token, "sk-ws-01-registered");
|
||||
assert_eq!(result.auth_config["auth_method"], json!("token"));
|
||||
assert_eq!(result.auth_config["register_source"], json!("new"));
|
||||
assert!(result.auth_config.get("id_token").is_none());
|
||||
assert_eq!(
|
||||
result.auth_config["id_token_fingerprint"],
|
||||
json!(secret_fingerprint("firebase-id-token"))
|
||||
);
|
||||
assert_eq!(
|
||||
result.auth_config["credential_fingerprint"],
|
||||
json!(secret_fingerprint("sk-ws-01-registered"))
|
||||
);
|
||||
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
|
||||
assert_eq!(result.auth_config["email_verified"], json!(true));
|
||||
assert_eq!(result.auth_config["account_id"], json!("acct-1"));
|
||||
assert_eq!(result.auth_config["primary_org_id"], json!("org-1"));
|
||||
assert_eq!(result.auth_config["plan_name"], json!("Pro"));
|
||||
let requests = executor.requests.lock().expect("requests lock");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0]
|
||||
.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("firebase_id_token"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("firebase-id-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_email_password_without_storing_password() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = WindsurfProviderOAuthAdapter;
|
||||
let result = adapter
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx(),
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: "windsurf".to_string(),
|
||||
name: None,
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(json!({
|
||||
"email": "alice@example.com",
|
||||
"password": "secret-password"
|
||||
})),
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("email password should import");
|
||||
|
||||
assert_eq!(
|
||||
result.token_set.access_token,
|
||||
"devin-session-token$password"
|
||||
);
|
||||
assert_eq!(result.auth_config["auth_method"], json!("email_password"));
|
||||
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
|
||||
assert_eq!(result.auth_config["email_verified"], json!(true));
|
||||
assert_eq!(result.auth_config["account_id"], json!("acct-password"));
|
||||
assert_eq!(result.auth_config["primary_org_id"], json!("org-password"));
|
||||
assert_eq!(result.auth_config["plan_name"], json!("Pro"));
|
||||
assert_eq!(
|
||||
result.auth_config["credential_fingerprint"],
|
||||
json!(secret_fingerprint("devin-session-token$password"))
|
||||
);
|
||||
assert!(result.auth_config.get("password").is_none());
|
||||
|
||||
let requests = executor.requests.lock().expect("requests lock");
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0]
|
||||
.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("password"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("secret-password")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1]
|
||||
.headers
|
||||
.get("x-devin-auth1-token")
|
||||
.map(String::as_str),
|
||||
Some("auth1-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_session_token_from_token_field_without_register_user() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = WindsurfProviderOAuthAdapter;
|
||||
let result = adapter
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx(),
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: "windsurf".to_string(),
|
||||
name: None,
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(json!({
|
||||
"token": "devin-session-token$abc",
|
||||
"email": "alice@example.com"
|
||||
})),
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("session token should import directly");
|
||||
|
||||
assert_eq!(result.token_set.access_token, "devin-session-token$abc");
|
||||
assert_eq!(result.auth_config["auth_method"], json!("api_key"));
|
||||
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
|
||||
assert_eq!(
|
||||
result.auth_config["credential_fingerprint"],
|
||||
json!(secret_fingerprint("devin-session-token$abc"))
|
||||
);
|
||||
assert!(executor.requests.lock().expect("requests lock").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_session_token_from_access_token_alias_without_register_user() {
|
||||
let executor = RecordingExecutor::default();
|
||||
let adapter = WindsurfProviderOAuthAdapter;
|
||||
let result = adapter
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx(),
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: "windsurf".to_string(),
|
||||
name: None,
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(json!({
|
||||
"access_token": "devin-session-token$alias",
|
||||
"email": "alice@example.com"
|
||||
})),
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("session token alias should import directly");
|
||||
|
||||
assert_eq!(result.token_set.access_token, "devin-session-token$alias");
|
||||
assert_eq!(result.auth_config["auth_method"], json!("api_key"));
|
||||
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
|
||||
assert!(executor.requests.lock().expect("requests lock").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_error_body_redacts_sensitive_fields() {
|
||||
let body = truncate_body(
|
||||
r#"{"error":"invalid","firebase_id_token":"firebase-id-token","sessionToken":"devin-session-token$abc","nested":{"apiKey":"sk-secret"}}"#,
|
||||
);
|
||||
|
||||
assert!(body.contains("[REDACTED]"));
|
||||
assert!(!body.contains("firebase-id-token"));
|
||||
assert!(!body.contains("devin-session-token$abc"));
|
||||
assert!(!body.contains("sk-secret"));
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,14 @@ impl ProviderOAuthService {
|
||||
pub fn with_builtin_adapters() -> Self {
|
||||
use super::providers::{
|
||||
AntigravityProviderOAuthAdapter, CodexProviderOAuthAdapter,
|
||||
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter,
|
||||
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter, WindsurfProviderOAuthAdapter,
|
||||
};
|
||||
|
||||
let mut service = Self::new()
|
||||
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
|
||||
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(WindsurfProviderOAuthAdapter));
|
||||
for provider_type in ["claude_code", "chatgpt_web", "gemini_cli"] {
|
||||
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
|
||||
service = service.with_adapter(Arc::new(adapter));
|
||||
@@ -128,6 +129,7 @@ mod tests {
|
||||
"gemini_cli",
|
||||
"antigravity",
|
||||
"kiro",
|
||||
"windsurf",
|
||||
] {
|
||||
assert!(
|
||||
service.adapter(provider_type).is_ok(),
|
||||
|
||||
@@ -17,14 +17,18 @@ pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||
pub use providers::{
|
||||
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
|
||||
build_codex_pool_quota_request, build_kiro_pool_quota_request,
|
||||
enrich_chatgpt_web_quota_metadata, grok_mode_id_for_model, grok_pool_tier_from_quota_bucket,
|
||||
grok_quota_window_key_for_model, grok_supported_quota_windows_for_tier,
|
||||
normalize_chatgpt_web_image_quota_limit, AntigravityProviderPoolAdapter,
|
||||
ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter, DefaultProviderPoolAdapter,
|
||||
GrokProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
||||
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
||||
build_windsurf_pool_model_configs_request,
|
||||
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
|
||||
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
|
||||
build_windsurf_pool_rate_limit_request_with_base_url, enrich_chatgpt_web_quota_metadata,
|
||||
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
|
||||
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
|
||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroPoolQuotaAuthInput,
|
||||
KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
||||
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL,
|
||||
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
||||
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION, WINDSURF_MODEL_CONFIGS_PATH,
|
||||
WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
|
||||
};
|
||||
pub use quota::{
|
||||
provider_pool_key_account_quota_exhausted, provider_pool_key_scheduling_label,
|
||||
@@ -69,7 +73,8 @@ mod tests {
|
||||
"gemini_cli",
|
||||
"grok",
|
||||
"kiro",
|
||||
"vertex_ai"
|
||||
"vertex_ai",
|
||||
"windsurf"
|
||||
]
|
||||
);
|
||||
assert!(service
|
||||
@@ -85,11 +90,12 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
service.provider_types_for_capability(ProviderPoolCapability::QuotaRefresh),
|
||||
["antigravity", "chatgpt_web", "codex", "grok", "kiro"]
|
||||
["antigravity", "chatgpt_web", "codex", "grok", "kiro", "windsurf"]
|
||||
);
|
||||
assert!(service.supports_quota_refresh("codex"));
|
||||
assert!(service.supports_quota_refresh("antigravity"));
|
||||
assert!(service.supports_quota_refresh("grok"));
|
||||
assert!(service.supports_quota_refresh("windsurf"));
|
||||
assert!(!service.supports_quota_refresh("gemini_cli"));
|
||||
assert_eq!(
|
||||
service.quota_refresh_unsupported_message("claude_code"),
|
||||
@@ -238,6 +244,110 @@ mod tests {
|
||||
assert_eq!(metadata["image_quota_used"], json!(33.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_quota_request_uses_user_status_connect_rpc() {
|
||||
let spec = build_windsurf_pool_quota_request("key-ws", "session-token-123");
|
||||
|
||||
assert_eq!(spec.request_id, "windsurf-quota:key-ws");
|
||||
assert_eq!(spec.method, "POST");
|
||||
assert_eq!(
|
||||
spec.url,
|
||||
format!("https://server.codeium.com{WINDSURF_USER_STATUS_PATH}")
|
||||
);
|
||||
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
|
||||
assert_eq!(
|
||||
spec.headers
|
||||
.get("connect-protocol-version")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.pointer("/metadata/apiKey"))
|
||||
.and_then(Value::as_str),
|
||||
Some("session-token-123")
|
||||
);
|
||||
assert_eq!(spec.provider_api_format, "windsurf:user_status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_model_and_rate_limit_requests_use_connect_rpc_metadata() {
|
||||
let models = build_windsurf_pool_model_configs_request("key-ws", "api-key-123");
|
||||
let rate_limit = build_windsurf_pool_rate_limit_request("key-ws", "api-key-123");
|
||||
|
||||
assert_eq!(
|
||||
models.url,
|
||||
format!("https://server.codeium.com{WINDSURF_MODEL_CONFIGS_PATH}")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit.url,
|
||||
format!("https://server.codeium.com{WINDSURF_RATE_LIMIT_PATH}")
|
||||
);
|
||||
for spec in [models, rate_limit] {
|
||||
assert_eq!(spec.method, "POST");
|
||||
assert_eq!(
|
||||
spec.headers
|
||||
.get("connect-protocol-version")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.pointer("/metadata/apiKey"))
|
||||
.and_then(Value::as_str),
|
||||
Some("api-key-123")
|
||||
);
|
||||
assert_eq!(spec.client_api_format, "openai:chat");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_rate_limit_metadata_keeps_member_schedulable() {
|
||||
let service = ProviderPoolService::with_builtin_adapters();
|
||||
let key = sample_key(Some(json!({
|
||||
"windsurf": {
|
||||
"updated_at": 1_700_000_000u64,
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"retry_after_ms": 60_000
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
let signals = service.member_signals("windsurf", &key, None);
|
||||
|
||||
assert!(!signals.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_status_snapshot_ban_marks_member_exhausted() {
|
||||
let service = ProviderPoolService::with_builtin_adapters();
|
||||
let mut key = sample_key(Some(json!({
|
||||
"windsurf": {
|
||||
"updated_at": 1_700_000_000u64,
|
||||
"daily_remaining_percent": 100.0
|
||||
}
|
||||
})));
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"provider_type": "windsurf",
|
||||
"code": "banned",
|
||||
"exhausted": false,
|
||||
"windows": [{
|
||||
"code": "daily",
|
||||
"used_ratio": 0.0,
|
||||
"remaining_ratio": 1.0
|
||||
}]
|
||||
}
|
||||
}));
|
||||
|
||||
let signals = service.member_signals("windsurf", &key, None);
|
||||
|
||||
assert!(signals.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_payload_derives_provider_support_from_capabilities() {
|
||||
let payload = build_admin_pool_scheduling_presets_payload();
|
||||
@@ -251,10 +361,14 @@ mod tests {
|
||||
.find(|item| item["name"] == "recent_refresh")
|
||||
.expect("recent_refresh should exist");
|
||||
|
||||
assert_eq!(free_first["providers"], json!(["codex", "grok", "kiro"]));
|
||||
assert_eq!(
|
||||
free_first["providers"],
|
||||
json!(["codex", "grok", "kiro", "windsurf"])
|
||||
);
|
||||
assert_eq!(
|
||||
recent_refresh["providers"],
|
||||
json!(["codex", "grok", "kiro"])
|
||||
json!(["codex", "grok", "kiro", "windsurf"])
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod default;
|
||||
pub mod grok;
|
||||
pub mod kiro;
|
||||
pub mod unsupported;
|
||||
pub mod windsurf;
|
||||
|
||||
pub use antigravity::AntigravityProviderPoolAdapter;
|
||||
pub use antigravity::{
|
||||
@@ -32,3 +33,11 @@ pub use unsupported::{
|
||||
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
|
||||
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
};
|
||||
pub use windsurf::{
|
||||
build_windsurf_pool_model_configs_request,
|
||||
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
|
||||
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
|
||||
build_windsurf_pool_rate_limit_request_with_base_url, WindsurfProviderPoolAdapter,
|
||||
WINDSURF_DEFAULT_BASE_URL, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
|
||||
WINDSURF_USER_STATUS_PATH,
|
||||
};
|
||||
|
||||
286
crates/aether-provider-pool/src/providers/windsurf.rs
Normal file
286
crates/aether-provider-pool/src/providers/windsurf.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
};
|
||||
use aether_pool_core::PoolSchedulingPreset;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::capability::ProviderPoolCapabilities;
|
||||
use crate::provider::{
|
||||
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
|
||||
ProviderPoolMemberInput,
|
||||
};
|
||||
use crate::quota::{
|
||||
provider_pool_json_bool, provider_pool_json_f64, provider_pool_member_quota_snapshot,
|
||||
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
|
||||
};
|
||||
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
|
||||
|
||||
pub const WINDSURF_DEFAULT_BASE_URL: &str = "https://server.codeium.com";
|
||||
pub const WINDSURF_USER_STATUS_PATH: &str =
|
||||
"/exa.seat_management_pb.SeatManagementService/GetUserStatus";
|
||||
pub const WINDSURF_MODEL_CONFIGS_PATH: &str =
|
||||
"/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs";
|
||||
pub const WINDSURF_RATE_LIMIT_PATH: &str =
|
||||
"/exa.api_server_pb.ApiServerService/CheckUserMessageRateLimit";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WindsurfProviderPoolAdapter;
|
||||
|
||||
impl ProviderPoolAdapter for WindsurfProviderPoolAdapter {
|
||||
fn provider_type(&self) -> &'static str {
|
||||
"windsurf"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderPoolCapabilities {
|
||||
ProviderPoolCapabilities {
|
||||
plan_tier: true,
|
||||
quota_reset: true,
|
||||
quota_refresh: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_scheduling_presets(&self) -> Vec<PoolSchedulingPreset> {
|
||||
vec![PoolSchedulingPreset {
|
||||
preset: "recent_refresh".to_string(),
|
||||
enabled: true,
|
||||
mode: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
|
||||
if windsurf_quota_snapshot_hard_exhausted(input.key, input.provider_type) {
|
||||
return true;
|
||||
}
|
||||
if let Some(exhausted) =
|
||||
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
|
||||
{
|
||||
return exhausted;
|
||||
}
|
||||
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
|
||||
.is_some_and(windsurf_quota_exhausted_from_bucket)
|
||||
}
|
||||
|
||||
fn quota_refresh_endpoint(
|
||||
&self,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
include_inactive: bool,
|
||||
) -> Option<StoredProviderCatalogEndpoint> {
|
||||
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||
provider_pool_endpoint_format_matches(endpoint, "openai:chat")
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_refresh_missing_endpoint_message(&self) -> String {
|
||||
"找不到有效的 openai:chat 端点".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn windsurf_quota_snapshot_hard_exhausted(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
) -> bool {
|
||||
provider_pool_member_quota_snapshot(key, provider_type)
|
||||
.and_then(|quota| quota.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.is_some_and(|code| matches!(code.as_str(), "banned" | "forbidden" | "quarantined"))
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_quota_request(
|
||||
key_id: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_pool_quota_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_quota_request_with_base_url(
|
||||
key_id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_connect_rpc_request(
|
||||
format!("windsurf-quota:{key_id}"),
|
||||
"windsurf:user_status",
|
||||
"windsurf-user-status",
|
||||
base_url,
|
||||
WINDSURF_USER_STATUS_PATH,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_model_configs_request(
|
||||
key_id: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_pool_model_configs_request_with_base_url(
|
||||
key_id,
|
||||
WINDSURF_DEFAULT_BASE_URL,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_model_configs_request_with_base_url(
|
||||
key_id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_connect_rpc_request(
|
||||
format!("windsurf-models:{key_id}"),
|
||||
"windsurf:model_configs",
|
||||
"windsurf-model-configs",
|
||||
base_url,
|
||||
WINDSURF_MODEL_CONFIGS_PATH,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_rate_limit_request(
|
||||
key_id: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_pool_rate_limit_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_rate_limit_request_with_base_url(
|
||||
key_id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_connect_rpc_request(
|
||||
format!("windsurf-rate-limit:{key_id}"),
|
||||
"windsurf:rate_limit",
|
||||
"windsurf-rate-limit",
|
||||
base_url,
|
||||
WINDSURF_RATE_LIMIT_PATH,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_windsurf_connect_rpc_request(
|
||||
request_id: String,
|
||||
provider_api_format: &str,
|
||||
model_name: &str,
|
||||
base_url: &str,
|
||||
path: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
let mut headers = BTreeMap::new();
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
headers.insert("accept".to_string(), "application/json".to_string());
|
||||
headers.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
headers.insert("user-agent".to_string(), "windsurf/1.9600.41".to_string());
|
||||
|
||||
ProviderPoolQuotaRequestSpec {
|
||||
request_id,
|
||||
provider_name: "windsurf".to_string(),
|
||||
quota_kind: "windsurf".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: format!("{}{}", base_url.trim_end_matches('/'), path),
|
||||
headers,
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(json!({
|
||||
"metadata": windsurf_metadata(api_key),
|
||||
})),
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
model_name: Some(model_name.to_string()),
|
||||
accept_invalid_certs: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn windsurf_metadata(api_key: &str) -> Value {
|
||||
json!({
|
||||
"apiKey": api_key,
|
||||
"ideName": "windsurf",
|
||||
"ideVersion": "1.9600.41",
|
||||
"extensionName": "windsurf",
|
||||
"extensionVersion": "1.9600.41",
|
||||
"locale": "en",
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn windsurf_quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
|
||||
if provider_pool_json_bool(bucket.get("banned"))
|
||||
.or_else(|| provider_pool_json_bool(bucket.get("quarantined")))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let daily_remaining = provider_pool_json_f64(bucket.get("daily_remaining_percent"));
|
||||
let weekly_remaining = provider_pool_json_f64(bucket.get("weekly_remaining_percent"));
|
||||
daily_remaining.is_some_and(|value| value <= 0.0)
|
||||
|| weekly_remaining.is_some_and(|value| value <= 0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{windsurf_quota_exhausted_from_bucket, windsurf_quota_snapshot_hard_exhausted};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_key_with_quota(code: &str, exhausted: bool) -> StoredProviderCatalogKey {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key-windsurf".to_string(),
|
||||
"provider-windsurf".to_string(),
|
||||
"windsurf@example.com".to_string(),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("sample key should build");
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"provider_type": "windsurf",
|
||||
"code": code,
|
||||
"exhausted": exhausted,
|
||||
"windows": [{
|
||||
"code": "daily",
|
||||
"used_ratio": 0.0,
|
||||
"remaining_ratio": 1.0
|
||||
}]
|
||||
}
|
||||
}));
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_rate_limit_bucket_does_not_mark_quota_exhausted() {
|
||||
let bucket = json!({
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"retry_after_ms": 60_000u64
|
||||
},
|
||||
"daily_remaining_percent": 50.0,
|
||||
"weekly_remaining_percent": 50.0
|
||||
});
|
||||
let bucket = bucket.as_object().expect("bucket should be object");
|
||||
|
||||
assert!(!windsurf_quota_exhausted_from_bucket(bucket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_banned_and_quarantined_snapshot_codes_are_hard_exhausted() {
|
||||
for code in ["banned", "forbidden", "quarantined"] {
|
||||
let key = sample_key_with_quota(code, false);
|
||||
|
||||
assert!(
|
||||
windsurf_quota_snapshot_hard_exhausted(&key, "windsurf"),
|
||||
"{code} should be hard exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
let cooldown_key = sample_key_with_quota("cooldown", false);
|
||||
assert!(!windsurf_quota_snapshot_hard_exhausted(
|
||||
&cooldown_key,
|
||||
"windsurf"
|
||||
));
|
||||
let rate_limited_key = sample_key_with_quota("rate_limited", false);
|
||||
assert!(!windsurf_quota_snapshot_hard_exhausted(
|
||||
&rate_limited_key,
|
||||
"windsurf"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||
use crate::providers::{
|
||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroProviderPoolAdapter,
|
||||
WindsurfProviderPoolAdapter,
|
||||
CLAUDE_CODE_PROVIDER_POOL_ADAPTER, GEMINI_CLI_PROVIDER_POOL_ADAPTER,
|
||||
VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
};
|
||||
@@ -54,6 +55,7 @@ impl ProviderPoolService {
|
||||
.with_adapter(Arc::new(GrokProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(KiroProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(WindsurfProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ mod standard;
|
||||
pub mod url;
|
||||
pub mod vertex;
|
||||
mod video;
|
||||
pub mod windsurf;
|
||||
|
||||
pub use aether_oauth as oauth;
|
||||
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
|
||||
@@ -131,3 +132,9 @@ pub use video::{
|
||||
resolve_video_create_auth, video_create_transport_unsupported_reason,
|
||||
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput, VideoTaskTransportSnapshotLookup,
|
||||
};
|
||||
pub use windsurf::{
|
||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||
build_windsurf_cascade_upstream_url, is_windsurf_provider_transport,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network, GET_CHAT_MESSAGE_PATH,
|
||||
WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
@@ -253,6 +253,17 @@ const GROK_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const WINDSURF_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
fixed_provider: true,
|
||||
api_format_inheritance: ProviderApiFormatInheritance::OAuthOrBearer,
|
||||
enable_format_conversion_by_default: true,
|
||||
oauth_is_bearer_like: true,
|
||||
supports_model_fetch: false,
|
||||
supports_local_openai_chat_transport: false,
|
||||
supports_local_same_format_transport: false,
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "claude_code",
|
||||
version: 1,
|
||||
@@ -405,6 +416,19 @@ const GROK_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplat
|
||||
runtime_policy: GROK_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
const WINDSURF_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "windsurf",
|
||||
version: 1,
|
||||
base_url: "https://server.codeium.com",
|
||||
endpoints: &[FixedProviderEndpointTemplate {
|
||||
item_key: "openai:chat",
|
||||
api_format: "openai:chat",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
}],
|
||||
runtime_policy: WINDSURF_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
|
||||
provider_runtime_policy(provider_type).fixed_provider
|
||||
}
|
||||
@@ -455,6 +479,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
|
||||
"gemini_cli" => Some(&GEMINI_CLI_FIXED_PROVIDER_TEMPLATE),
|
||||
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
|
||||
"antigravity" => Some(&ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE),
|
||||
"windsurf" => Some(&WINDSURF_FIXED_PROVIDER_TEMPLATE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -565,6 +590,17 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
|
||||
redirect_uri: "http://localhost:51121/oauth2callback",
|
||||
use_pkce: true,
|
||||
}),
|
||||
"windsurf" => Some(ProviderOAuthTemplate {
|
||||
provider_type: "windsurf",
|
||||
display_name: "Windsurf",
|
||||
authorize_url: "https://windsurf.com/windsurf/signin",
|
||||
token_url: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser",
|
||||
client_id: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u",
|
||||
client_secret: "",
|
||||
scopes: &[],
|
||||
redirect_uri: "show-auth-token",
|
||||
use_pkce: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -575,16 +611,18 @@ pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] = &[
|
||||
"chatgpt_web",
|
||||
"gemini_cli",
|
||||
"antigravity",
|
||||
"windsurf",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
fixed_provider_endpoint_template_by_api_format, fixed_provider_key_inherits_api_formats,
|
||||
fixed_provider_template, provider_runtime_policy,
|
||||
fixed_provider_template, provider_runtime_policy, provider_type_admin_oauth_template,
|
||||
provider_type_allows_auth_channel_mismatch_by_default, provider_type_oauth_is_bearer_like,
|
||||
provider_type_supports_local_embedding_transport,
|
||||
provider_type_supports_local_same_format_transport, FixedProviderEndpointConfigValue,
|
||||
ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -675,6 +713,47 @@ mod tests {
|
||||
assert!(!template.runtime_policy.supports_local_same_format_transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_fixed_provider_template_exposes_openai_chat() {
|
||||
let template = fixed_provider_template("windsurf").expect("windsurf template should exist");
|
||||
assert_eq!(template.provider_type, "windsurf");
|
||||
assert_eq!(template.base_url, "https://server.codeium.com");
|
||||
assert_eq!(template.version, 1);
|
||||
assert_eq!(
|
||||
template
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|item| item.api_format)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["openai:chat"]
|
||||
);
|
||||
assert!(
|
||||
fixed_provider_endpoint_template_by_api_format("windsurf", "openai:chat").is_some()
|
||||
);
|
||||
|
||||
let policy = provider_runtime_policy("windsurf");
|
||||
assert!(policy.fixed_provider);
|
||||
assert!(policy.enable_format_conversion_by_default);
|
||||
assert!(policy.oauth_is_bearer_like);
|
||||
assert!(!policy.supports_model_fetch);
|
||||
assert!(!policy.supports_local_same_format_transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_admin_oauth_template_is_advertised() {
|
||||
let template =
|
||||
provider_type_admin_oauth_template("windsurf").expect("windsurf oauth template");
|
||||
|
||||
assert_eq!(template.provider_type, "windsurf");
|
||||
assert_eq!(template.display_name, "Windsurf");
|
||||
assert_eq!(
|
||||
template.authorize_url,
|
||||
"https://windsurf.com/windsurf/signin"
|
||||
);
|
||||
assert_eq!(template.redirect_uri, "show-auth-token");
|
||||
assert!(ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES.contains(&"windsurf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
|
||||
466
crates/aether-provider-transport/src/windsurf.rs
Normal file
466
crates/aether-provider-transport/src/windsurf.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::rules::{
|
||||
apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers,
|
||||
body_rules_are_locally_supported, header_rules_are_locally_supported,
|
||||
};
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::build_passthrough_path_url;
|
||||
use crate::{
|
||||
resolve_transport_profile, should_skip_upstream_passthrough_header,
|
||||
supports_local_oauth_request_auth_resolution, transport_profile_is_configured,
|
||||
transport_proxy_is_locally_supported,
|
||||
};
|
||||
|
||||
pub const PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
pub const GET_CHAT_MESSAGE_PATH: &str = "/exa.api_server_pb.ApiServerService/GetChatMessage";
|
||||
const DEFAULT_IDE_VERSION: &str = "1.9600.41";
|
||||
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
||||
|
||||
pub fn is_windsurf_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROVIDER_TYPE)
|
||||
}
|
||||
|
||||
pub fn local_windsurf_request_transport_unsupported_reason_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
if !transport.provider.is_active {
|
||||
return Some("provider_inactive");
|
||||
}
|
||||
if !transport.endpoint.is_active {
|
||||
return Some("endpoint_inactive");
|
||||
}
|
||||
if !transport.key.is_active {
|
||||
return Some("key_inactive");
|
||||
}
|
||||
if !is_windsurf_provider_transport(transport) {
|
||||
return Some("transport_provider_type_unsupported");
|
||||
}
|
||||
if !transport
|
||||
.endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:chat")
|
||||
{
|
||||
return Some("transport_api_format_mismatch");
|
||||
}
|
||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
||||
return Some("transport_header_rules_unsupported");
|
||||
}
|
||||
if !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) {
|
||||
return Some("transport_body_rules_unsupported");
|
||||
}
|
||||
if transport.key.decrypted_auth_config.is_some()
|
||||
&& !supports_local_oauth_request_auth_resolution(transport)
|
||||
&& !supports_local_windsurf_request_auth_resolution(transport)
|
||||
{
|
||||
return Some("transport_oauth_resolution_unsupported");
|
||||
}
|
||||
if !transport_proxy_is_locally_supported(transport) {
|
||||
return Some("transport_proxy_unsupported");
|
||||
}
|
||||
if transport_profile_is_configured(transport) && resolve_transport_profile(transport).is_none()
|
||||
{
|
||||
return Some("transport_profile_unsupported");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_local_windsurf_request_auth_resolution(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
resolve_windsurf_cascade_auth(transport).is_some()
|
||||
}
|
||||
|
||||
pub fn resolve_windsurf_cascade_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<(String, String)> {
|
||||
if !is_windsurf_provider_transport(transport) {
|
||||
return None;
|
||||
}
|
||||
let auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
|
||||
if !matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer") {
|
||||
return None;
|
||||
}
|
||||
let secret = transport.key.decrypted_api_key.trim();
|
||||
if secret.is_empty() || secret == PLACEHOLDER_API_KEY {
|
||||
return None;
|
||||
}
|
||||
Some(("authorization".to_string(), format!("Bearer {secret}")))
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_upstream_url(
|
||||
upstream_base_url: &str,
|
||||
query: Option<&str>,
|
||||
) -> Option<String> {
|
||||
build_passthrough_path_url(upstream_base_url, GET_CHAT_MESSAGE_PATH, query, &[])
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
auth_value: &str,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: Option<&http::HeaderMap>,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let messages = body_json.get("messages")?.as_array()?.clone();
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let conversation_id =
|
||||
extract_conversation_id(body_json).unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let message_text = last_user_message_text(&messages).unwrap_or_else(|| "Continue.".to_string());
|
||||
let mut provider_request_body = json!({
|
||||
"metadata": windsurf_metadata_from_auth(auth_value),
|
||||
"model": mapped_model,
|
||||
"modelName": mapped_model,
|
||||
"stream": upstream_is_stream,
|
||||
"conversationId": conversation_id,
|
||||
"message": message_text,
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
if let Some(max_tokens) = body_json
|
||||
.get("max_tokens")
|
||||
.or_else(|| body_json.get("maxTokens"))
|
||||
{
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("maxTokens".to_string(), max_tokens.clone());
|
||||
}
|
||||
if let Some(temperature) = body_json.get("temperature") {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("temperature".to_string(), temperature.clone());
|
||||
}
|
||||
if let Some(top_p) = body_json.get("top_p").or_else(|| body_json.get("topP")) {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("topP".to_string(), top_p.clone());
|
||||
}
|
||||
|
||||
if !apply_local_body_rules_with_request_headers(
|
||||
&mut provider_request_body,
|
||||
body_rules,
|
||||
Some(body_json),
|
||||
request_headers,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_headers(
|
||||
headers: &http::HeaderMap,
|
||||
provider_request_body: &Value,
|
||||
original_request_body: &Value,
|
||||
header_rules: Option<&Value>,
|
||||
auth_header: &str,
|
||||
auth_value: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, value) in headers {
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let key = name.as_str().to_ascii_lowercase();
|
||||
if should_skip_upstream_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
out.insert(key, value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let auth_header = auth_header.trim().to_ascii_lowercase();
|
||||
if !apply_local_header_rules_with_request_headers(
|
||||
&mut out,
|
||||
header_rules,
|
||||
&[
|
||||
auth_header.as_str(),
|
||||
"content-type",
|
||||
"connect-protocol-version",
|
||||
],
|
||||
provider_request_body,
|
||||
Some(original_request_body),
|
||||
Some(headers),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
out.insert("content-type".to_string(), "application/json".to_string());
|
||||
out.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
out.insert(
|
||||
"user-agent".to_string(),
|
||||
format!("windsurf/{DEFAULT_IDE_VERSION}"),
|
||||
);
|
||||
out.insert(
|
||||
"accept".to_string(),
|
||||
if upstream_is_stream {
|
||||
"text/event-stream".to_string()
|
||||
} else {
|
||||
"application/json".to_string()
|
||||
},
|
||||
);
|
||||
if !auth_header.is_empty() {
|
||||
out.insert(auth_header, auth_value.trim().to_string());
|
||||
}
|
||||
out.remove("content-length");
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn windsurf_metadata_from_auth(auth_value: &str) -> Value {
|
||||
json!({
|
||||
"apiKey": auth_secret_from_header_value(auth_value),
|
||||
"ideName": "windsurf",
|
||||
"ideVersion": DEFAULT_IDE_VERSION,
|
||||
"extensionName": "windsurf",
|
||||
"extensionVersion": DEFAULT_IDE_VERSION,
|
||||
"locale": "en",
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_secret_from_header_value(auth_value: &str) -> String {
|
||||
let value = auth_value.trim();
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or(value)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_conversation_id(body_json: &Value) -> Option<String> {
|
||||
let object = body_json.as_object()?;
|
||||
string_value(object.get("conversation_id"))
|
||||
.or_else(|| string_value(object.get("conversationId")))
|
||||
.or_else(|| string_value(object.get("session_id")))
|
||||
.or_else(|| string_value(object.get("sessionId")))
|
||||
.or_else(|| {
|
||||
object
|
||||
.get("metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| {
|
||||
string_value(metadata.get("conversation_id"))
|
||||
.or_else(|| string_value(metadata.get("conversationId")))
|
||||
.or_else(|| string_value(metadata.get("session_id")))
|
||||
.or_else(|| string_value(metadata.get("sessionId")))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn string_value(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn last_user_message_text(messages: &[Value]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(Value::as_object)
|
||||
.find(|message| {
|
||||
message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| role == "user")
|
||||
})
|
||||
.and_then(|message| openai_content_to_text(message.get("content")))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn openai_content_to_text(value: Option<&Value>) -> Option<String> {
|
||||
match value? {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Array(items) => {
|
||||
let parts = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(!parts.is_empty()).then(|| parts.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{
|
||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||
build_windsurf_cascade_upstream_url,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||
resolve_windsurf_cascade_auth, GET_CHAT_MESSAGE_PATH,
|
||||
};
|
||||
|
||||
fn sample_windsurf_transport(auth_type: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-windsurf".to_string(),
|
||||
name: "Windsurf".to_string(),
|
||||
provider_type: "windsurf".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-windsurf-chat".to_string(),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://server.codeium.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-windsurf".to_string(),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
name: "windsurf@example.com".to_string(),
|
||||
auth_type: auth_type.to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "devin-session-token$abc".to_string(),
|
||||
decrypted_auth_config: Some(r#"{"provider_type":"windsurf"}"#.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_windsurf_cascade_url() {
|
||||
assert_eq!(
|
||||
build_windsurf_cascade_upstream_url("https://server.codeium.com", Some("debug=1"))
|
||||
.as_deref(),
|
||||
Some(
|
||||
"https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage?debug=1"
|
||||
)
|
||||
);
|
||||
assert!(GET_CHAT_MESSAGE_PATH.ends_with("/GetChatMessage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_request_body_with_metadata_and_messages() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5",
|
||||
"conversation_id": "conv-1",
|
||||
"messages": [
|
||||
{"role": "system", "content": "brief"},
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
||||
],
|
||||
"max_tokens": 128
|
||||
}),
|
||||
"windsurf-model",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["metadata"]["apiKey"], json!("devin-session-token$abc"));
|
||||
assert_eq!(body["modelName"], json!("windsurf-model"));
|
||||
assert_eq!(body["stream"], json!(true));
|
||||
assert_eq!(body["conversationId"], json!("conv-1"));
|
||||
assert_eq!(body["message"], json!("hello"));
|
||||
assert_eq!(body["maxTokens"], json!(128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_headers_with_connect_protocol_and_auth() {
|
||||
let headers = build_windsurf_cascade_headers(
|
||||
&HeaderMap::new(),
|
||||
&json!({"metadata": {"apiKey": "secret"}}),
|
||||
&json!({"messages": []}),
|
||||
None,
|
||||
"authorization",
|
||||
"Bearer secret",
|
||||
false,
|
||||
)
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("connect-protocol-version").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer secret")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_windsurf_transport_resolves_direct_bearer_auth() {
|
||||
let transport = sample_windsurf_transport("oauth");
|
||||
|
||||
assert_eq!(
|
||||
local_windsurf_request_transport_unsupported_reason_with_network(&transport),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_windsurf_cascade_auth(&transport),
|
||||
Some((
|
||||
"authorization".to_string(),
|
||||
"Bearer devin-session-token$abc".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1739,7 +1739,7 @@ fn clone_usage_capture_value(value: Option<&Value>) -> Option<Value> {
|
||||
}
|
||||
|
||||
fn clone_usage_body_value(value: Option<&Value>) -> Option<Value> {
|
||||
value.cloned()
|
||||
value.cloned().map(mask_sensitive_body_fields)
|
||||
}
|
||||
|
||||
fn sanitize_usage_event_capture_fields(mut data: UsageEventData) -> UsageEventData {
|
||||
@@ -2057,6 +2057,52 @@ fn mask_sensitive_headers_in_json_value(value: Option<Value>) -> Option<Value> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn mask_sensitive_body_fields(mut value: Value) -> Value {
|
||||
mask_sensitive_body_fields_in_place(&mut value);
|
||||
value
|
||||
}
|
||||
|
||||
fn mask_sensitive_body_fields_in_place(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object.iter_mut() {
|
||||
if is_sensitive_body_key(key) {
|
||||
let replacement = if value.is_null() {
|
||||
Value::Null
|
||||
} else if let Some(text) = value.as_str() {
|
||||
Value::String(mask_sensitive_header_value(text))
|
||||
} else {
|
||||
Value::String(mask_sensitive_header_value(&value.to_string()))
|
||||
};
|
||||
*value = replacement;
|
||||
} else {
|
||||
mask_sensitive_body_fields_in_place(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
mask_sensitive_body_fields_in_place(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_body_key(key: &str) -> bool {
|
||||
let normalized = key
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
normalized.contains("token")
|
||||
|| normalized.contains("apikey")
|
||||
|| normalized.contains("password")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized == "cookie"
|
||||
}
|
||||
|
||||
fn resolve_error_category(status_code: u16, event_type: UsageEventType) -> Option<String> {
|
||||
match event_type {
|
||||
UsageEventType::Cancelled => Some("cancelled".to_string()),
|
||||
@@ -2970,10 +3016,10 @@ mod tests {
|
||||
build_sync_terminal_usage_seed, build_terminal_usage_context_seed,
|
||||
build_terminal_usage_event_from_seed, build_usage_event_data_seed,
|
||||
extract_token_counts_from_json, extract_token_counts_from_value, headers_to_json,
|
||||
mask_header_value, mask_sensitive_headers_in_json_value, parse_sse_body_for_storage,
|
||||
resolve_error_message, trim_owned_non_empty_string, LifecycleUsageSeed, TerminalUsageSeed,
|
||||
UsageBodyRefsSeed, UsageBodyStatesSeed, UsageRoutingSeed, UsageTerminalState,
|
||||
MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
|
||||
mask_header_value, mask_sensitive_body_fields, mask_sensitive_headers_in_json_value,
|
||||
parse_sse_body_for_storage, resolve_error_message, trim_owned_non_empty_string,
|
||||
LifecycleUsageSeed, TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed,
|
||||
UsageRoutingSeed, UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
|
||||
};
|
||||
use crate::{
|
||||
build_upsert_usage_record_from_event, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
@@ -3378,6 +3424,114 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_body_capture_redacts_nested_provider_secrets() {
|
||||
let masked = mask_sensitive_body_fields(json!({
|
||||
"metadata": {
|
||||
"apiKey": "devin-session-token$secret-value",
|
||||
"nested": {
|
||||
"sessionToken": "session-token-secret"
|
||||
}
|
||||
},
|
||||
"password": "plain-password",
|
||||
"messages": [{"content": "safe text"}]
|
||||
}));
|
||||
|
||||
assert_ne!(
|
||||
masked.pointer("/metadata/apiKey").and_then(Value::as_str),
|
||||
Some("devin-session-token$secret-value")
|
||||
);
|
||||
assert_ne!(
|
||||
masked
|
||||
.pointer("/metadata/nested/sessionToken")
|
||||
.and_then(Value::as_str),
|
||||
Some("session-token-secret")
|
||||
);
|
||||
assert_ne!(
|
||||
masked.get("password").and_then(Value::as_str),
|
||||
Some("plain-password")
|
||||
);
|
||||
assert_eq!(
|
||||
masked
|
||||
.pointer("/messages/0/content")
|
||||
.and_then(Value::as_str),
|
||||
Some("safe text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_terminal_usage_redacts_provider_request_body_secrets_from_context() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-sync-redact-provider-request-1".to_string(),
|
||||
candidate_id: Some("cand-sync-redact-provider-request-1".to_string()),
|
||||
provider_name: Some("Windsurf".to_string()),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
endpoint_id: "endpoint-windsurf".to_string(),
|
||||
key_id: "key-windsurf".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage"
|
||||
.to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"model": "windsurf-model"})),
|
||||
stream: false,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
model_name: Some("windsurf-model".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let payload = GatewaySyncReportRequest {
|
||||
trace_id: "trace-sync-redact-provider-request-1".to_string(),
|
||||
report_kind: "openai_chat_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:chat",
|
||||
"provider_request_body": {
|
||||
"metadata": {
|
||||
"apiKey": "devin-session-token$abc",
|
||||
"sessionToken": "session-token-secret"
|
||||
},
|
||||
"message": "safe prompt"
|
||||
}
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body_json: Some(json!({"id": "resp_1", "choices": []})),
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
telemetry: None,
|
||||
};
|
||||
|
||||
let event =
|
||||
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
|
||||
.expect("terminal usage should build");
|
||||
let provider_request = event
|
||||
.data
|
||||
.provider_request_body
|
||||
.as_ref()
|
||||
.expect("provider request body should be captured");
|
||||
|
||||
assert_ne!(
|
||||
provider_request
|
||||
.pointer("/metadata/apiKey")
|
||||
.and_then(Value::as_str),
|
||||
Some("devin-session-token$abc")
|
||||
);
|
||||
assert_ne!(
|
||||
provider_request
|
||||
.pointer("/metadata/sessionToken")
|
||||
.and_then(Value::as_str),
|
||||
Some("session-token-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request.pointer("/message").and_then(Value::as_str),
|
||||
Some("safe prompt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_stream_terminal_usage_from_provider_body_and_preserves_client_body() {
|
||||
let plan = ExecutionPlan {
|
||||
|
||||
@@ -79,6 +79,137 @@ export interface OAuthBatchImportTaskStatusResponse {
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type BatchImportCredentialsNormalization =
|
||||
| { ok: true; isBatch: boolean; credentials: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
function getImportCredentialLines(text: string): Array<{ lineNumber: number; text: string }> {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line, index) => ({ lineNumber: index + 1, text: line.trim() }))
|
||||
.filter(line => line.text && !line.text.startsWith('#'))
|
||||
}
|
||||
|
||||
function jsonParseErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function normalizeBatchImportItem(
|
||||
value: unknown,
|
||||
location: string,
|
||||
): { ok: true; value: string | Record<string, unknown> } | { ok: false; message: string } {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) return { ok: true, value: trimmed }
|
||||
return { ok: false, message: `${location} 不能为空字符串` }
|
||||
}
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return { ok: true, value: value as Record<string, unknown> }
|
||||
}
|
||||
return { ok: false, message: `${location} 必须是 JSON 对象或字符串` }
|
||||
}
|
||||
|
||||
function normalizeBatchImportArray(items: unknown[]): BatchImportCredentialsNormalization {
|
||||
if (items.length === 0) {
|
||||
return { ok: false, message: 'JSON 数组不能为空' }
|
||||
}
|
||||
|
||||
const normalized: Array<string | Record<string, unknown>> = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
const result = normalizeBatchImportItem(item, `JSON 数组第 ${index + 1} 项`)
|
||||
if (!result.ok) return result
|
||||
normalized.push(result.value)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
isBatch: true,
|
||||
credentials: JSON.stringify(normalized),
|
||||
}
|
||||
}
|
||||
|
||||
function parseImportCredentialLines(
|
||||
lines: Array<{ lineNumber: number; text: string }>,
|
||||
): BatchImportCredentialsNormalization {
|
||||
const normalized: Array<string | Record<string, unknown>> = []
|
||||
|
||||
for (const line of lines) {
|
||||
const firstChar = line.text[0]
|
||||
if (firstChar === '{' || firstChar === '[') {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line.text)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `JSON Lines 格式无效,请检查第 ${line.lineNumber} 行: ${jsonParseErrorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const [index, item] of parsed.entries()) {
|
||||
const result = normalizeBatchImportItem(item, `第 ${line.lineNumber} 行数组第 ${index + 1} 项`)
|
||||
if (!result.ok) return result
|
||||
normalized.push(result.value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = normalizeBatchImportItem(parsed, `第 ${line.lineNumber} 行`)
|
||||
if (!result.ok) return result
|
||||
normalized.push(result.value)
|
||||
continue
|
||||
}
|
||||
|
||||
normalized.push(line.text)
|
||||
}
|
||||
|
||||
return normalizeBatchImportArray(normalized)
|
||||
}
|
||||
|
||||
export function normalizeBatchImportCredentials(text: string): BatchImportCredentialsNormalization {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
return { ok: false, message: '请输入凭据数据' }
|
||||
}
|
||||
|
||||
const firstChar = trimmed[0]
|
||||
if (firstChar === '[') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { ok: false, message: 'JSON 批量凭据必须是数组' }
|
||||
}
|
||||
return normalizeBatchImportArray(parsed)
|
||||
} catch (error) {
|
||||
return { ok: false, message: `JSON 数组格式无效: ${jsonParseErrorMessage(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
if (firstChar === '{') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return { ok: true, isBatch: false, credentials: trimmed }
|
||||
}
|
||||
return { ok: false, message: '单条 JSON 凭据必须是对象' }
|
||||
} catch (error) {
|
||||
const lines = getImportCredentialLines(trimmed)
|
||||
if (lines.length > 1) {
|
||||
return parseImportCredentialLines(lines)
|
||||
}
|
||||
return { ok: false, message: `JSON 格式无效: ${jsonParseErrorMessage(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
const lines = getImportCredentialLines(trimmed)
|
||||
if (lines.length > 1) {
|
||||
return parseImportCredentialLines(lines)
|
||||
}
|
||||
|
||||
return { ok: true, isBatch: false, credentials: trimmed }
|
||||
}
|
||||
|
||||
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
|
||||
return resp.data
|
||||
@@ -102,8 +233,14 @@ export async function completeProviderLevelOAuth(
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: {
|
||||
api_key?: string
|
||||
apiKey?: string
|
||||
token?: string
|
||||
auth_token?: string
|
||||
authToken?: string
|
||||
refresh_token?: string
|
||||
access_token?: string
|
||||
password?: string
|
||||
expires_at?: number
|
||||
name?: string
|
||||
proxy_node_id?: string
|
||||
@@ -150,7 +287,8 @@ export async function getBatchImportOAuthTaskStatus(
|
||||
export interface DeviceAuthorizeRequest {
|
||||
start_url?: string
|
||||
region?: string
|
||||
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github'
|
||||
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
|
||||
login_option?: 'google' | 'github' | 'default'
|
||||
redirect_uri?: string
|
||||
proxy_node_id?: string
|
||||
}
|
||||
@@ -170,6 +308,7 @@ export interface DeviceAuthorizeResponse {
|
||||
export interface DevicePollRequest {
|
||||
session_id: string
|
||||
callback_url?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface DevicePollResponse {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
FailoverRulesConfig,
|
||||
PoolAdvancedConfig,
|
||||
ProviderConfig,
|
||||
ProviderType,
|
||||
ProviderWithEndpointsSummary,
|
||||
ProxyConfig,
|
||||
} from './types'
|
||||
@@ -92,7 +93,7 @@ export async function updateProvider(
|
||||
providerId: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
|
||||
provider_type: ProviderType
|
||||
description: string | null
|
||||
website: string
|
||||
provider_priority: number
|
||||
@@ -127,7 +128,7 @@ export async function updateProvider(
|
||||
export async function createProvider(
|
||||
data: {
|
||||
name: string
|
||||
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok'
|
||||
provider_type?: ProviderType
|
||||
description?: string
|
||||
website?: string
|
||||
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||
|
||||
@@ -370,6 +370,32 @@ export interface KiroUpstreamMetadata {
|
||||
banned_at?: number // 封禁时间(Unix 时间戳,秒)
|
||||
}
|
||||
|
||||
// Windsurf 上游配额信息
|
||||
export interface WindsurfUpstreamMetadata {
|
||||
updated_at?: number
|
||||
plan_name?: string
|
||||
daily_remaining_percent?: number | null
|
||||
weekly_remaining_percent?: number | null
|
||||
daily_reset_at?: number | null
|
||||
weekly_reset_at?: number | null
|
||||
prompt_used?: number | null
|
||||
prompt_limit?: number | null
|
||||
prompt_remaining?: number | null
|
||||
flex_used?: number | null
|
||||
flex_limit?: number | null
|
||||
flex_remaining?: number | null
|
||||
allowed_models_count?: number | null
|
||||
models?: Array<{
|
||||
model_uid?: string | null
|
||||
label?: string | null
|
||||
provider?: string | null
|
||||
supports_images?: boolean | null
|
||||
credit_multiplier?: number | null
|
||||
}> | null
|
||||
rate_limit?: Record<string, unknown> | null
|
||||
last_error?: string | null
|
||||
}
|
||||
|
||||
export interface ChatGPTWebUpstreamMetadata {
|
||||
updated_at?: number // Unix 时间戳(秒)
|
||||
plan_type?: string | null
|
||||
@@ -439,6 +465,7 @@ export interface UpstreamMetadata {
|
||||
codex?: CodexUpstreamMetadata
|
||||
antigravity?: AntigravityUpstreamMetadata
|
||||
kiro?: KiroUpstreamMetadata
|
||||
windsurf?: WindsurfUpstreamMetadata
|
||||
chatgpt_web?: ChatGPTWebUpstreamMetadata
|
||||
grok?: GrokUpstreamMetadata
|
||||
balance_query?: BalanceQueryUpstreamMetadata
|
||||
@@ -569,7 +596,7 @@ export interface PublicEndpointStatusMonitorResponse {
|
||||
formats: PublicEndpointStatusMonitor[]
|
||||
}
|
||||
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'vertex_ai'
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
|
||||
|
||||
export interface ClaudeCodeAdvancedConfig {
|
||||
// 会话数量控制:null/undefined 表示不限制
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface QuotaStatusSnapshot {
|
||||
plan_type?: string | null
|
||||
pool_tier?: string | null
|
||||
credits?: QuotaCreditsSnapshot | null
|
||||
allowed_models_count?: number | null
|
||||
rate_limit?: Record<string, unknown> | null
|
||||
windows?: QuotaWindowSnapshot[] | null
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
]"
|
||||
@click="switchMode('oauth')"
|
||||
>
|
||||
{{ isKiroProvider ? '设备授权' : '获取授权' }}
|
||||
{{ isDeviceBrowserProvider ? (isWindsurfProvider ? '浏览器登录' : '设备授权') : '获取授权' }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
@@ -93,8 +93,132 @@
|
||||
class="space-y-4 transition-opacity duration-150"
|
||||
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<!-- Windsurf: 浏览器 session/poll 授权 -->
|
||||
<template v-if="isWindsurfProvider">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<button
|
||||
v-for="opt in ([
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'google', label: 'Google' },
|
||||
{ key: 'github', label: 'GitHub' },
|
||||
] as const)"
|
||||
:key="opt.key"
|
||||
class="h-8 text-xs font-medium rounded-md border transition-colors"
|
||||
:class="device.auth_type === opt.key
|
||||
? 'border-primary bg-primary/5 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/20'"
|
||||
@click="selectWindsurfLoginOption(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="device.status === 'error' || device.status === 'expired'"
|
||||
class="rounded-xl border border-destructive/20 bg-destructive/5 p-5"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center space-y-3">
|
||||
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle class="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ device.error || '请重试' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="resetDevice"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="device.starting && !device.session_id"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备登录...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往登录</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="device.starting || device.completing || !device.verification_uri_complete"
|
||||
@click="openDeviceVerificationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="device.starting || device.completing || !device.verification_uri_complete"
|
||||
@click="copyToClipboard(device.verification_uri_complete)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!device.session_id"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="device.starting"
|
||||
@click="startDeviceAuth"
|
||||
>
|
||||
开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL 或 token</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
v-model="device.callback_url"
|
||||
:disabled="device.completing"
|
||||
:placeholder="deviceCallbackPlaceholder"
|
||||
class="min-h-[150px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="device.session_id && device.status === 'pending'"
|
||||
class="pl-6 flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
|
||||
<span>会话剩余 {{ deviceCountdownFormatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Kiro: 设备授权模式 -->
|
||||
<template v-if="isKiroProvider">
|
||||
<template v-else-if="isKiroProvider">
|
||||
<div class="space-y-3">
|
||||
<!-- 授权类型切换 -->
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
@@ -198,7 +322,7 @@
|
||||
<Textarea
|
||||
v-model="device.callback_url"
|
||||
:disabled="device.completing"
|
||||
:placeholder="kiroSocialCallbackPlaceholder"
|
||||
:placeholder="deviceCallbackPlaceholder"
|
||||
class="h-full min-h-0 overflow-y-auto text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -527,15 +651,15 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth' && showAuthorizationMode && !isKiroProvider"
|
||||
v-if="mode === 'oauth' && showAuthorizationMode && !isDeviceBrowserProvider"
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth' && isKiroSocialManualCallbackMode"
|
||||
:disabled="!canCompleteKiroSocialDeviceAuth"
|
||||
v-if="mode === 'oauth' && isManualDeviceCallbackMode"
|
||||
:disabled="!canCompleteDeviceAuth"
|
||||
@click="completeDeviceAuth"
|
||||
>
|
||||
{{ device.completing ? '验证中...' : '验证' }}
|
||||
@@ -577,6 +701,7 @@ import {
|
||||
getBatchImportOAuthTaskStatus,
|
||||
startDeviceAuthorize,
|
||||
pollDeviceAuthorize,
|
||||
normalizeBatchImportCredentials,
|
||||
getAwsRegions,
|
||||
} from '@/api/endpoints'
|
||||
import type {
|
||||
@@ -678,7 +803,8 @@ let oauthInitRequestId = 0
|
||||
let oauthCompleteRequestId = 0
|
||||
|
||||
// 设备授权状态
|
||||
type DeviceAuthType = 'google' | 'github' | 'builder_id' | 'identity_center'
|
||||
type DeviceAuthType = 'default' | 'google' | 'github' | 'builder_id' | 'identity_center'
|
||||
type WindsurfLoginOption = 'default' | 'google' | 'github'
|
||||
|
||||
interface DeviceAuthState {
|
||||
auth_type: DeviceAuthType
|
||||
@@ -741,6 +867,8 @@ const isOpen = computed(() => props.open)
|
||||
|
||||
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
|
||||
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
|
||||
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
|
||||
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
|
||||
const showAuthorizationMode = computed(() => !isGrokProvider.value)
|
||||
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
|
||||
|
||||
@@ -752,14 +880,20 @@ const isKiroSocialManualCallbackMode = computed(() =>
|
||||
isKiroProvider.value && isSocialDeviceAuth.value
|
||||
)
|
||||
|
||||
const isKiroSocialManualCallbackPending = computed(() =>
|
||||
isKiroSocialManualCallbackMode.value
|
||||
const isManualDeviceCallbackMode = computed(() =>
|
||||
isKiroSocialManualCallbackMode.value || isWindsurfProvider.value
|
||||
)
|
||||
|
||||
const isManualDeviceCallbackPending = computed(() =>
|
||||
isManualDeviceCallbackMode.value
|
||||
&& device.value.session_id.length > 0
|
||||
&& device.value.status === 'pending'
|
||||
)
|
||||
|
||||
const kiroSocialCallbackPlaceholder = computed(() =>
|
||||
`http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
|
||||
const deviceCallbackPlaceholder = computed(() =>
|
||||
isWindsurfProvider.value
|
||||
? `粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权`
|
||||
: `http://localhost:49153/oauth/callback?login_option=${device.value.auth_type}&code=...&state=...`
|
||||
)
|
||||
|
||||
const deviceCountdownFormatted = computed(() => {
|
||||
@@ -779,8 +913,8 @@ const canCompleteOAuth = computed(() => {
|
||||
return !oauthBusy.value
|
||||
})
|
||||
|
||||
const canCompleteKiroSocialDeviceAuth = computed(() => {
|
||||
if (!isKiroSocialManualCallbackPending.value) return false
|
||||
const canCompleteDeviceAuth = computed(() => {
|
||||
if (!isManualDeviceCallbackPending.value) return false
|
||||
if (!device.value.callback_url.trim()) return false
|
||||
return !device.value.starting && !device.value.completing
|
||||
})
|
||||
@@ -957,6 +1091,7 @@ function resetDeviceRuntimeState() {
|
||||
}
|
||||
|
||||
function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
|
||||
if (!isKiroProvider.value) return false
|
||||
if (device.value.starting) {
|
||||
return !isSocialDeviceAuth.value
|
||||
}
|
||||
@@ -967,6 +1102,14 @@ function isKiroDeviceAuthOptionDisabled(_authType: DeviceAuthType): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function selectWindsurfLoginOption(loginOption: WindsurfLoginOption) {
|
||||
if (!isWindsurfProvider.value) return
|
||||
if (device.value.auth_type === loginOption && device.value.session_id && device.value.status === 'pending') return
|
||||
deviceAuthRequestId += 1
|
||||
resetDeviceRuntimeState()
|
||||
device.value.auth_type = loginOption
|
||||
}
|
||||
|
||||
function selectDeviceAuthType(authType: DeviceAuthType) {
|
||||
if (device.value.auth_type === authType) return
|
||||
if (isKiroDeviceAuthOptionDisabled(authType)) return
|
||||
@@ -985,11 +1128,11 @@ function resetDevice() {
|
||||
totp.stop()
|
||||
const { auth_type, start_url, region, totp_secret } = device.value
|
||||
device.value = createInitialDeviceState()
|
||||
device.value.auth_type = auth_type
|
||||
device.value.auth_type = isWindsurfProvider.value ? (auth_type === 'google' || auth_type === 'github' ? auth_type : 'default') : auth_type
|
||||
device.value.start_url = start_url
|
||||
device.value.region = region
|
||||
device.value.totp_secret = totp_secret
|
||||
if (device.value.auth_type === 'google' || device.value.auth_type === 'github') {
|
||||
if (!isWindsurfProvider.value && (device.value.auth_type === 'google' || device.value.auth_type === 'github')) {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1146,9 @@ function resetForm() {
|
||||
stopDevicePolling()
|
||||
totp.stop()
|
||||
device.value = createInitialDeviceState()
|
||||
if (isWindsurfProvider.value) {
|
||||
device.value.auth_type = 'default'
|
||||
}
|
||||
importText.value = ''
|
||||
importing.value = false
|
||||
importTask.value = null
|
||||
@@ -1046,7 +1192,7 @@ function openAuthorizationUrl() {
|
||||
async function initOAuth() {
|
||||
if (!props.providerId) return
|
||||
if (!showAuthorizationMode.value) return
|
||||
if (isKiroProvider.value) return
|
||||
if (isDeviceBrowserProvider.value) return
|
||||
if (oauth.value.starting) return
|
||||
|
||||
const requestId = ++oauthInitRequestId
|
||||
@@ -1095,35 +1241,12 @@ async function handleCompleteOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为批量导入格式
|
||||
function isBatchImport(text: string): boolean {
|
||||
const trimmed = text.trim()
|
||||
// JSON 数组(含单元素数组)
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return Array.isArray(parsed) && parsed.length >= 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 单个 JSON 对象(可能是 pretty-printed 多行)不算批量导入
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return false // 可解析的单个 JSON 对象,走单条导入
|
||||
} catch {
|
||||
// 解析失败:可能是多个 JSON 对象(JSON Lines 格式),继续检查
|
||||
}
|
||||
}
|
||||
// 多行文本(纯 Token 一行一个)
|
||||
const lines = trimmed.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
|
||||
return lines.length > 1
|
||||
}
|
||||
|
||||
function parseImportText(text: string): {
|
||||
api_key?: string
|
||||
token?: string
|
||||
refresh_token?: string
|
||||
access_token?: string
|
||||
password?: string
|
||||
expires_at?: number
|
||||
name?: string
|
||||
email?: string
|
||||
@@ -1154,6 +1277,35 @@ function parseImportText(text: string): {
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindsurfProvider.value) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const apiKey = normalizeStringField(obj.api_key) ?? normalizeStringField(obj.apiKey)
|
||||
const token = normalizeStringField(obj.token) ?? normalizeStringField(obj.auth_token) ?? normalizeStringField(obj.authToken)
|
||||
const refreshToken = normalizeStringField(obj.refresh_token) ?? normalizeStringField(obj.refreshToken)
|
||||
const accessToken = normalizeStringField(obj.access_token) ?? normalizeStringField(obj.accessToken)
|
||||
const email = normalizeStringField(obj.email)
|
||||
const password = normalizeStringField(obj.password)
|
||||
if (apiKey || token || refreshToken || accessToken || (email && password)) {
|
||||
return {
|
||||
api_key: apiKey,
|
||||
token,
|
||||
refresh_token: refreshToken,
|
||||
access_token: accessToken,
|
||||
email,
|
||||
password,
|
||||
name: normalizeStringField(obj.name) ?? email,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: treat as token copied from show-auth-token.
|
||||
}
|
||||
return { token: trimmed }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
@@ -1325,13 +1477,19 @@ async function handleImport() {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedCredentials = normalizeBatchImportCredentials(inputText)
|
||||
if (!normalizedCredentials.ok) {
|
||||
showError(normalizedCredentials.message, '格式错误')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
let keepImporting = false
|
||||
try {
|
||||
const proxyNodeId = selectedProxyNodeId.value || undefined
|
||||
// Kiro 的单条 JSON 凭据也必须走 batch-import 路径,后端需要完整 auth_config。
|
||||
if (isKiroProvider.value || isBatchImport(inputText)) {
|
||||
const task = await startBatchImportOAuthTask(props.providerId, inputText, proxyNodeId)
|
||||
if (isKiroProvider.value || normalizedCredentials.isBatch) {
|
||||
const task = await startBatchImportOAuthTask(props.providerId, normalizedCredentials.credentials, proxyNodeId)
|
||||
importTask.value = {
|
||||
task_id: task.task_id,
|
||||
provider_id: props.providerId,
|
||||
@@ -1356,7 +1514,7 @@ async function handleImport() {
|
||||
scheduleImportPoll(task.task_id, 400)
|
||||
} else {
|
||||
// 单条导入
|
||||
const parsed = parseImportText(inputText)
|
||||
const parsed = parseImportText(normalizedCredentials.credentials)
|
||||
if (!parsed) {
|
||||
showError('无法解析输入内容,请检查格式', '格式错误')
|
||||
return
|
||||
@@ -1413,12 +1571,18 @@ async function startDeviceAuth() {
|
||||
device.value.starting = true
|
||||
device.value.error = ''
|
||||
try {
|
||||
const isWindsurf = isWindsurfProvider.value
|
||||
const isBuilderID = requestedAuthType === 'builder_id'
|
||||
const isSocial = requestedAuthType === 'google' || requestedAuthType === 'github'
|
||||
const windsurfLoginOption: WindsurfLoginOption = isSocial ? requestedAuthType : 'default'
|
||||
const authTypeForRequest = isWindsurf
|
||||
? 'browser'
|
||||
: (requestedAuthType === 'default' ? 'google' : requestedAuthType)
|
||||
const resp = await startDeviceAuthorize(props.providerId, {
|
||||
auth_type: requestedAuthType,
|
||||
start_url: isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined)),
|
||||
region: isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined),
|
||||
auth_type: authTypeForRequest,
|
||||
login_option: isWindsurf ? windsurfLoginOption : undefined,
|
||||
start_url: isWindsurf ? undefined : (isBuilderID ? BUILDER_ID_START_URL : (isSocial ? undefined : (device.value.start_url.trim() || undefined))),
|
||||
region: isWindsurf ? undefined : (isBuilderID || isSocial ? BUILDER_ID_REGION : (device.value.region.trim() || undefined)),
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
if (requestId !== deviceAuthRequestId || device.value.auth_type !== requestedAuthType) return
|
||||
@@ -1428,7 +1592,7 @@ async function startDeviceAuth() {
|
||||
device.value.verification_uri_complete = resp.verification_uri_complete
|
||||
device.value.expires_at = Date.now() + resp.expires_in * 1000
|
||||
device.value.interval = resp.interval || 5
|
||||
device.value.callback_required = resp.callback_required === true || isSocial
|
||||
device.value.callback_required = resp.callback_required === true || isSocial || isWindsurf
|
||||
device.value.status = 'pending'
|
||||
startCountdown()
|
||||
if (!device.value.callback_required) {
|
||||
@@ -1464,7 +1628,7 @@ function scheduleDevicePoll() {
|
||||
}
|
||||
|
||||
async function completeDeviceAuth() {
|
||||
if (device.value.completing || !canCompleteKiroSocialDeviceAuth.value) return
|
||||
if (device.value.completing || !canCompleteDeviceAuth.value) return
|
||||
device.value.completing = true
|
||||
try {
|
||||
await pollDevice(true)
|
||||
@@ -1473,13 +1637,39 @@ async function completeDeviceAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindsurfSubmittedCredential(value: string): { callback_url?: string, token?: string } {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return {}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { callback_url: trimmed }
|
||||
}
|
||||
|
||||
const query = trimmed.replace(/^[?#&]+/, '')
|
||||
const params = new URLSearchParams(query)
|
||||
const hasTokenParam = ['token', 'auth_token', 'access_token'].some(key => params.has(key))
|
||||
const hasStateParam = params.has('state')
|
||||
if (hasTokenParam && hasStateParam) {
|
||||
return { callback_url: `https://windsurf.com/show-auth-token?${query}` }
|
||||
}
|
||||
if (hasTokenParam) {
|
||||
return { token: params.get('token') || params.get('auth_token') || params.get('access_token') || trimmed }
|
||||
}
|
||||
|
||||
return { token: trimmed }
|
||||
}
|
||||
|
||||
async function pollDevice(withCallback = false) {
|
||||
if (!props.providerId || !device.value.session_id || device.value.status !== 'pending') return
|
||||
|
||||
try {
|
||||
const submittedCredential = withCallback ? device.value.callback_url.trim() : ''
|
||||
const windsurfSubmitted = isWindsurfProvider.value
|
||||
? normalizeWindsurfSubmittedCredential(submittedCredential)
|
||||
: {}
|
||||
const result = await pollDeviceAuthorize(props.providerId, {
|
||||
session_id: device.value.session_id,
|
||||
callback_url: withCallback ? device.value.callback_url.trim() : undefined,
|
||||
callback_url: withCallback ? (windsurfSubmitted.callback_url || (!isWindsurfProvider.value ? submittedCredential : undefined)) : undefined,
|
||||
token: withCallback ? windsurfSubmitted.token : undefined,
|
||||
})
|
||||
|
||||
switch (result.status) {
|
||||
@@ -1535,7 +1725,9 @@ watch(() => props.open, (newOpen) => {
|
||||
if (!showAuthorizationMode.value) {
|
||||
return
|
||||
}
|
||||
if (isKiroProvider.value) {
|
||||
if (isWindsurfProvider.value) {
|
||||
device.value.auth_type = 'default'
|
||||
} else if (isKiroProvider.value) {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
} else {
|
||||
initOAuth()
|
||||
@@ -1552,7 +1744,11 @@ watch(
|
||||
mode.value = 'import'
|
||||
return
|
||||
}
|
||||
if (props.open && isKiroProvider.value && mode.value === 'oauth') {
|
||||
if (props.open && isWindsurfProvider.value && mode.value === 'oauth') {
|
||||
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
|
||||
? device.value.auth_type
|
||||
: 'default'
|
||||
} else if (props.open && isKiroProvider.value && mode.value === 'oauth') {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -960,6 +960,138 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Windsurf 上游额度信息 -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'windsurf' && (hasWindsurfQuotaDisplayData(key) || isWindsurfUnavailableKey(key) || isWindsurfExhaustedKey(key))"
|
||||
class="mt-2 p-2 rounded-md"
|
||||
:class="isWindsurfUnavailableKey(key) ? 'bg-destructive/10 border border-destructive/30' : (isWindsurfExhaustedKey(key) ? 'bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/50' : 'bg-muted/30')"
|
||||
>
|
||||
<div
|
||||
v-if="isWindsurfUnavailableKey(key)"
|
||||
class="flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ShieldX class="w-4 h-4 shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[11px] font-medium">
|
||||
账号不可用
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.last_error"
|
||||
class="text-[10px] text-destructive/80 truncate"
|
||||
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
|
||||
>
|
||||
{{ getWindsurfQuotaDisplay(key)?.last_error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="isWindsurfExhaustedKey(key)"
|
||||
class="mb-2 flex items-center gap-2 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<ShieldX class="w-4 h-4 shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[11px] font-medium">
|
||||
{{ getWindsurfQuotaStatusLabel(key) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.last_error"
|
||||
class="text-[10px] text-amber-700/80 dark:text-amber-300/80 truncate"
|
||||
:title="getWindsurfQuotaDisplay(key)?.last_error || ''"
|
||||
>
|
||||
{{ getWindsurfQuotaDisplay(key)?.last_error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<RefreshCw
|
||||
v-if="refreshingQuota"
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="getWindsurfQuotaDisplay(key)?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatKiroUpdatedAt(getWindsurfQuotaDisplay(key)?.updated_at || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-if="getWindsurfQuotaDisplay(key)?.daily_remaining_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">日额度</span>
|
||||
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)">
|
||||
{{ (getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.daily_used_percent || 0)"
|
||||
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.daily_remaining_percent || 0, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.daily_reset_at"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.daily_reset_at || 0) }}重置
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getWindsurfQuotaDisplay(key)?.weekly_remaining_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">周额度</span>
|
||||
<span :class="getQuotaRemainingClass(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)">
|
||||
{{ (getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(getWindsurfQuotaDisplay(key)?.weekly_used_percent || 0)"
|
||||
:style="{ width: `${Math.max(getWindsurfQuotaDisplay(key)?.weekly_remaining_percent || 0, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="getWindsurfQuotaDisplay(key)?.weekly_reset_at"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{{ formatKiroResetTime(getWindsurfQuotaDisplay(key)?.weekly_reset_at || 0) }}重置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasWindsurfPromptQuota(key) || hasWindsurfFlexQuota(key)"
|
||||
class="mt-2 flex items-center gap-3 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span v-if="hasWindsurfPromptQuota(key)">
|
||||
Prompt {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_used || 0) }} /
|
||||
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.prompt_limit || 0) }}
|
||||
</span>
|
||||
<span v-if="hasWindsurfFlexQuota(key)">
|
||||
Flex {{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_used || 0) }} /
|
||||
{{ formatKiroUsage(getWindsurfQuotaDisplay(key)?.flex_limit || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasWindsurfModelCount(key) || hasWindsurfModelPreview(key)"
|
||||
class="mt-2 flex items-center justify-between gap-2 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span>
|
||||
模型 {{ getWindsurfQuotaDisplay(key)?.allowed_models_count ?? getWindsurfQuotaDisplay(key)?.models?.length }} 个
|
||||
</span>
|
||||
<span
|
||||
v-if="getWindsurfModelPreview(key)"
|
||||
class="truncate"
|
||||
:title="getWindsurfModelPreview(key) || ''"
|
||||
>
|
||||
{{ getWindsurfModelPreview(key) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- ChatGPT Web 上游额度信息(生图配额) -->
|
||||
<div
|
||||
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
|
||||
@@ -1357,6 +1489,7 @@ import type {
|
||||
ChatGPTWebUpstreamMetadata,
|
||||
GrokUpstreamMetadata,
|
||||
KiroUpstreamMetadata,
|
||||
WindsurfUpstreamMetadata,
|
||||
QuotaStatusSnapshot,
|
||||
QuotaWindowSnapshot,
|
||||
} from '@/api/endpoints/types'
|
||||
@@ -2185,7 +2318,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// Codex / Antigravity / Kiro / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
// Codex / Antigravity / Kiro / Windsurf / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
||||
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
||||
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
||||
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
||||
@@ -2204,7 +2337,7 @@ function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefin
|
||||
|
||||
function getQuotaSnapshotForProvider(
|
||||
key: EndpointAPIKey,
|
||||
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
|
||||
providerType: 'codex' | 'kiro' | 'windsurf' | 'antigravity' | 'chatgpt_web' | 'gemini_cli' | 'grok',
|
||||
): QuotaStatusSnapshot | null {
|
||||
const quota = key.status_snapshot?.quota
|
||||
if (!quota) return null
|
||||
@@ -2463,11 +2596,137 @@ function getGrokQuotaDisplay(key: EndpointAPIKey): GrokQuotaDisplay | null {
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
|
||||
type WindsurfQuotaDisplay = WindsurfUpstreamMetadata & {
|
||||
daily_used_percent?: number
|
||||
weekly_used_percent?: number
|
||||
}
|
||||
|
||||
function getWindsurfQuotaDisplay(key: EndpointAPIKey): WindsurfQuotaDisplay | null {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||||
const upstream = key.upstream_metadata?.windsurf
|
||||
if (!quota && !upstream) return null
|
||||
|
||||
const display: WindsurfQuotaDisplay = {}
|
||||
const updatedAt = getQuotaSnapshotUpdatedAt(quota) ?? upstream?.updated_at
|
||||
if (updatedAt !== undefined) display.updated_at = updatedAt
|
||||
if (quota?.plan_type) display.plan_name = quota.plan_type
|
||||
else if (upstream?.plan_name) display.plan_name = upstream.plan_name
|
||||
if (quota?.reason) display.last_error = quota.reason
|
||||
else if (upstream?.last_error) display.last_error = upstream.last_error
|
||||
if (typeof quota?.allowed_models_count === 'number') display.allowed_models_count = quota.allowed_models_count
|
||||
else if (typeof upstream?.allowed_models_count === 'number') display.allowed_models_count = upstream.allowed_models_count
|
||||
if (quota?.rate_limit) display.rate_limit = quota.rate_limit
|
||||
else if (upstream?.rate_limit) display.rate_limit = upstream.rate_limit
|
||||
if (Array.isArray(upstream?.models)) display.models = upstream.models
|
||||
|
||||
const dailyWindow = getQuotaWindow(quota, 'daily')
|
||||
const dailyRemaining = getQuotaWindowRemainingPercent(dailyWindow)
|
||||
const dailyUsed = getQuotaWindowUsedPercent(dailyWindow)
|
||||
if (dailyRemaining !== undefined) display.daily_remaining_percent = dailyRemaining
|
||||
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_remaining_percent = upstream.daily_remaining_percent
|
||||
if (dailyUsed !== undefined) display.daily_used_percent = dailyUsed
|
||||
else if (typeof upstream?.daily_remaining_percent === 'number') display.daily_used_percent = Math.max(100 - upstream.daily_remaining_percent, 0)
|
||||
const dailyResetAt = getQuotaWindowResetAt(dailyWindow)
|
||||
if (dailyResetAt !== undefined) display.daily_reset_at = dailyResetAt
|
||||
else if (typeof upstream?.daily_reset_at === 'number') display.daily_reset_at = upstream.daily_reset_at
|
||||
|
||||
const weeklyWindow = getQuotaWindow(quota, 'weekly')
|
||||
const weeklyRemaining = getQuotaWindowRemainingPercent(weeklyWindow)
|
||||
const weeklyUsed = getQuotaWindowUsedPercent(weeklyWindow)
|
||||
if (weeklyRemaining !== undefined) display.weekly_remaining_percent = weeklyRemaining
|
||||
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_remaining_percent = upstream.weekly_remaining_percent
|
||||
if (weeklyUsed !== undefined) display.weekly_used_percent = weeklyUsed
|
||||
else if (typeof upstream?.weekly_remaining_percent === 'number') display.weekly_used_percent = Math.max(100 - upstream.weekly_remaining_percent, 0)
|
||||
const weeklyResetAt = getQuotaWindowResetAt(weeklyWindow)
|
||||
if (weeklyResetAt !== undefined) display.weekly_reset_at = weeklyResetAt
|
||||
else if (typeof upstream?.weekly_reset_at === 'number') display.weekly_reset_at = upstream.weekly_reset_at
|
||||
|
||||
const promptWindow = getQuotaWindow(quota, 'prompt')
|
||||
if (typeof promptWindow?.used_value === 'number') display.prompt_used = promptWindow.used_value
|
||||
else if (typeof upstream?.prompt_used === 'number') display.prompt_used = upstream.prompt_used
|
||||
if (typeof promptWindow?.limit_value === 'number') display.prompt_limit = promptWindow.limit_value
|
||||
else if (typeof upstream?.prompt_limit === 'number') display.prompt_limit = upstream.prompt_limit
|
||||
if (typeof promptWindow?.remaining_value === 'number') display.prompt_remaining = promptWindow.remaining_value
|
||||
else if (typeof upstream?.prompt_remaining === 'number') display.prompt_remaining = upstream.prompt_remaining
|
||||
|
||||
const flexWindow = getQuotaWindow(quota, 'flex')
|
||||
if (typeof flexWindow?.used_value === 'number') display.flex_used = flexWindow.used_value
|
||||
else if (typeof upstream?.flex_used === 'number') display.flex_used = upstream.flex_used
|
||||
if (typeof flexWindow?.limit_value === 'number') display.flex_limit = flexWindow.limit_value
|
||||
else if (typeof upstream?.flex_limit === 'number') display.flex_limit = upstream.flex_limit
|
||||
if (typeof flexWindow?.remaining_value === 'number') display.flex_remaining = flexWindow.remaining_value
|
||||
else if (typeof upstream?.flex_remaining === 'number') display.flex_remaining = upstream.flex_remaining
|
||||
|
||||
return Object.keys(display).length > 0 ? display : null
|
||||
}
|
||||
|
||||
function hasGrokQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const grok = getGrokQuotaDisplay(key)
|
||||
return !!grok && (grok.usage_percentage !== undefined || grok.usage_limit !== undefined)
|
||||
}
|
||||
|
||||
function hasWindsurfQuotaDisplayData(key: EndpointAPIKey): boolean {
|
||||
const windsurf = getWindsurfQuotaDisplay(key)
|
||||
return !!windsurf && (
|
||||
windsurf.daily_remaining_percent !== undefined
|
||||
|| windsurf.weekly_remaining_percent !== undefined
|
||||
|| windsurf.prompt_limit !== undefined
|
||||
|| windsurf.flex_limit !== undefined
|
||||
|| windsurf.allowed_models_count !== undefined
|
||||
|| windsurf.rate_limit !== undefined
|
||||
|| !!windsurf.last_error
|
||||
|| (Array.isArray(windsurf.models) && windsurf.models.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function isWindsurfUnavailableKey(key: EndpointAPIKey): boolean {
|
||||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||||
return code === 'banned' || code === 'forbidden' || code === 'quarantined'
|
||||
}
|
||||
|
||||
function isWindsurfExhaustedKey(key: EndpointAPIKey): boolean {
|
||||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||||
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown'
|
||||
}
|
||||
|
||||
function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||||
const label = quota?.label?.trim()
|
||||
if (label) return label
|
||||
const code = String(quota?.code || '').trim().toLowerCase()
|
||||
return code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown' ? '速率受限' : '额度耗尽'
|
||||
}
|
||||
|
||||
function getWindsurfModelPreview(key: EndpointAPIKey): string | null {
|
||||
const models = getWindsurfQuotaDisplay(key)?.models
|
||||
if (!Array.isArray(models) || models.length === 0) return null
|
||||
return models
|
||||
.slice(0, 3)
|
||||
.map(model => (model.label || model.model_uid || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' / ') || null
|
||||
}
|
||||
|
||||
function hasFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function hasWindsurfPromptQuota(key: EndpointAPIKey): boolean {
|
||||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.prompt_limit)
|
||||
}
|
||||
|
||||
function hasWindsurfFlexQuota(key: EndpointAPIKey): boolean {
|
||||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.flex_limit)
|
||||
}
|
||||
|
||||
function hasWindsurfModelCount(key: EndpointAPIKey): boolean {
|
||||
return hasFiniteNumber(getWindsurfQuotaDisplay(key)?.allowed_models_count)
|
||||
}
|
||||
|
||||
function hasWindsurfModelPreview(key: EndpointAPIKey): boolean {
|
||||
return !!getWindsurfModelPreview(key)
|
||||
}
|
||||
|
||||
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
|
||||
image_quota_remaining_percent?: number
|
||||
image_quota_used_percent?: number
|
||||
@@ -2680,7 +2939,7 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro / ChatGPT Web)
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro / Windsurf / ChatGPT Web)
|
||||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||||
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||||
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||||
@@ -2757,6 +3016,28 @@ function shouldAutoRefreshGrokQuota(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldAutoRefreshWindsurfQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'windsurf') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
for (const { key } of allKeys.value) {
|
||||
if (!key.is_active) continue
|
||||
|
||||
if (isTokenExpiringSoon(key, now)) return true
|
||||
|
||||
if (!hasWindsurfQuotaDisplayData(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const updatedAt = getWindsurfQuotaDisplay(key)?.updated_at
|
||||
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldAutoRefreshChatGPTWebQuota(): boolean {
|
||||
if (provider.value?.provider_type !== 'chatgpt_web') return false
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -2856,14 +3137,14 @@ function applyQuotaResults(
|
||||
return applied
|
||||
}
|
||||
|
||||
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro 和 ChatGPT Web)
|
||||
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
||||
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
|
||||
const providerId = props.providerId
|
||||
if (!providerId) return
|
||||
if (refreshingQuota.value) return
|
||||
|
||||
const providerType = provider.value?.provider_type
|
||||
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return
|
||||
|
||||
// 检查是否需要刷新
|
||||
let shouldRefresh = false
|
||||
@@ -2875,6 +3156,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
||||
shouldRefresh = shouldAutoRefreshKiroQuota()
|
||||
} else if (providerType === 'grok') {
|
||||
shouldRefresh = shouldAutoRefreshGrokQuota()
|
||||
} else if (providerType === 'windsurf') {
|
||||
shouldRefresh = shouldAutoRefreshWindsurfQuota()
|
||||
} else if (providerType === 'chatgpt_web') {
|
||||
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
||||
}
|
||||
@@ -2890,6 +3173,8 @@ async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
|
||||
} else if (providerType === 'grok') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key))
|
||||
} else if (providerType === 'windsurf') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key))
|
||||
} else if (providerType === 'chatgpt_web') {
|
||||
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
<SelectItem value="kiro">
|
||||
Kiro
|
||||
</SelectItem>
|
||||
<SelectItem value="windsurf">
|
||||
Windsurf
|
||||
</SelectItem>
|
||||
<SelectItem value="antigravity">
|
||||
Antigravity
|
||||
</SelectItem>
|
||||
@@ -96,6 +99,9 @@
|
||||
<SelectItem value="kiro">
|
||||
Kiro
|
||||
</SelectItem>
|
||||
<SelectItem value="windsurf">
|
||||
Windsurf
|
||||
</SelectItem>
|
||||
<SelectItem value="antigravity">
|
||||
Antigravity
|
||||
</SelectItem>
|
||||
@@ -342,6 +348,7 @@ import {
|
||||
createProvider,
|
||||
normalizePoolAdvancedConfig,
|
||||
updateProvider,
|
||||
type ProviderType,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -377,7 +384,7 @@ const defaultPriority = computed(() => {
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
name: '',
|
||||
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok',
|
||||
provider_type: 'custom' as ProviderType,
|
||||
description: '',
|
||||
website: '',
|
||||
// 计费配置
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeBatchImportCredentials } from '@/api/endpoints/provider_oauth'
|
||||
import { isKeyManagedProviderType, isOAuthAccountProviderType } from '../providerTypeUtils'
|
||||
|
||||
describe('providerTypeUtils', () => {
|
||||
@@ -14,4 +15,47 @@ describe('providerTypeUtils', () => {
|
||||
expect(isOAuthAccountProviderType('GROK')).toBe(true)
|
||||
expect(isKeyManagedProviderType('grok')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats Windsurf as an OAuth account provider', () => {
|
||||
expect(isOAuthAccountProviderType('windsurf')).toBe(true)
|
||||
expect(isOAuthAccountProviderType('Windsurf')).toBe(true)
|
||||
expect(isKeyManagedProviderType('windsurf')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBatchImportCredentials', () => {
|
||||
it('converts JSON Lines objects into a JSON array payload', () => {
|
||||
const result = normalizeBatchImportCredentials([
|
||||
'{"refresh_token":"rt-1","email":"one@example.com"}',
|
||||
'{"token":"token-2","email":"two@example.com"}',
|
||||
].join('\n'))
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
isBatch: true,
|
||||
credentials: JSON.stringify([
|
||||
{ refresh_token: 'rt-1', email: 'one@example.com' },
|
||||
{ token: 'token-2', email: 'two@example.com' },
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed JSON Lines instead of treating them as raw tokens', () => {
|
||||
const result = normalizeBatchImportCredentials('{"refresh_token":"rt-1"}\n{"refresh_token":')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.message).toContain('第 2 行')
|
||||
}
|
||||
})
|
||||
|
||||
it('converts multiple raw token lines into a JSON array payload', () => {
|
||||
const result = normalizeBatchImportCredentials('token-a\n# comment\ntoken-b')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
isBatch: true,
|
||||
credentials: JSON.stringify(['token-a', 'token-b']),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([
|
||||
'antigravity',
|
||||
'kiro',
|
||||
'grok',
|
||||
'windsurf',
|
||||
])
|
||||
|
||||
export const isOAuthAccountProviderType = (providerType?: string | null): boolean =>
|
||||
|
||||
@@ -104,4 +104,74 @@ describe('providerKeyQuota', () => {
|
||||
},
|
||||
}, 'grok')).toBe('Auto剩余 40.0% (60/150) | Heavy剩余 0.0% (0/20)')
|
||||
})
|
||||
|
||||
it('surfaces Windsurf hard account states', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'windsurf',
|
||||
code: 'quarantined',
|
||||
label: '账号隔离中',
|
||||
exhausted: false,
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('账号隔离中')
|
||||
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'windsurf',
|
||||
code: 'cooldown',
|
||||
label: '冷却中',
|
||||
exhausted: false,
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('冷却中')
|
||||
})
|
||||
|
||||
it('includes Windsurf quota windows and model availability in display text', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'windsurf',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
allowed_models_count: 7,
|
||||
windows: [
|
||||
{
|
||||
code: 'daily',
|
||||
remaining_ratio: 0.75,
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
remaining_ratio: 0.5,
|
||||
},
|
||||
{
|
||||
code: 'prompt',
|
||||
remaining_value: 12,
|
||||
limit_value: 20,
|
||||
},
|
||||
{
|
||||
code: 'flex',
|
||||
used_value: 2,
|
||||
limit_value: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('日剩余 75.0% | 周剩余 50.0% | Prompt 剩余 12/20 | Flex 剩余 3/5 | 可用模型 7 个')
|
||||
})
|
||||
|
||||
it('uses Windsurf model availability when no quota window is present', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'windsurf',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
allowed_models_count: 3,
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('可用模型 3 个')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,6 +214,53 @@ function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
function getWindsurfQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const code = normalizeText(quota.code)?.toLowerCase()
|
||||
if (code === 'banned' || code === 'forbidden' || code === 'quarantined') {
|
||||
return normalizeText(quota.label) || '账号不可用'
|
||||
}
|
||||
if (code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown') {
|
||||
return normalizeText(quota.label) || '速率受限'
|
||||
}
|
||||
if (code === 'exhausted') {
|
||||
return normalizeText(quota.label) || '额度已耗尽'
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
const dailyRemaining = getQuotaWindowRemainingPercent(getQuotaWindow(quota, 'daily'))
|
||||
const weeklyRemaining = getQuotaWindowRemainingPercent(getQuotaWindow(quota, 'weekly'))
|
||||
if (dailyRemaining != null) parts.push(`日剩余 ${formatPercent(dailyRemaining)}`)
|
||||
if (weeklyRemaining != null) parts.push(`周剩余 ${formatPercent(weeklyRemaining)}`)
|
||||
|
||||
for (const [label, code] of [
|
||||
['Prompt', 'prompt'],
|
||||
['Flex', 'flex'],
|
||||
] as const) {
|
||||
const window = getQuotaWindow(quota, code)
|
||||
if (!window) continue
|
||||
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
parts.push(`${label} 剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`)
|
||||
continue
|
||||
}
|
||||
if (typeof window.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
parts.push(`${label} 剩余 ${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`)
|
||||
continue
|
||||
}
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent != null) {
|
||||
parts.push(`${label} 剩余 ${formatPercent(remainingPercent)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof quota.allowed_models_count === 'number') {
|
||||
parts.push(`可用模型 ${quota.allowed_models_count} 个`)
|
||||
}
|
||||
|
||||
if (parts.length > 0) return parts.join(' | ')
|
||||
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
function getAntigravityQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const code = normalizeText(quota.code)?.toLowerCase()
|
||||
if (code === 'forbidden') {
|
||||
@@ -312,6 +359,8 @@ export function getQuotaSnapshotFallbackText(
|
||||
return getKiroQuotaText(quota)
|
||||
case 'grok':
|
||||
return getGrokQuotaText(quota)
|
||||
case 'windsurf':
|
||||
return getWindsurfQuotaText(quota)
|
||||
case 'antigravity':
|
||||
return getAntigravityQuotaText(quota)
|
||||
case 'gemini_cli':
|
||||
|
||||
@@ -2076,6 +2076,7 @@ const showAccountQuotaColumn = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'gemini_cli'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'windsurf'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
@@ -2475,6 +2476,7 @@ function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
|
||||
const quotaRefreshSupported = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'windsurf'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
@@ -3622,11 +3624,15 @@ function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title:
|
||||
if (!quota) return null
|
||||
|
||||
const code = String(quota.code || '').trim().toLowerCase()
|
||||
if (code !== 'banned' && code !== 'forbidden') return null
|
||||
if (!['banned', 'forbidden', 'quarantined', 'rate_limited', 'exhausted'].includes(code)) return null
|
||||
|
||||
let label = String(quota.label || '').trim()
|
||||
if (!label) {
|
||||
label = code === 'banned' ? '账号封禁' : '访问受限'
|
||||
if (code === 'banned') label = '账号封禁'
|
||||
else if (code === 'forbidden') label = '访问受限'
|
||||
else if (code === 'quarantined') label = '账号隔离'
|
||||
else if (code === 'rate_limited') label = '速率受限'
|
||||
else label = '额度耗尽'
|
||||
} else if (label === '账号已封禁' || label === '封禁') {
|
||||
label = '账号封禁'
|
||||
}
|
||||
@@ -3683,6 +3689,7 @@ function normalizeQuotaLabel(label: string): string {
|
||||
}
|
||||
|
||||
function getQuotaProgressLabel(label: string): string {
|
||||
if (label === '日') return '日'
|
||||
if (label === '5H') return '5H'
|
||||
if (label === '周') return '周'
|
||||
if (label === 'Spark5H') return 'Spark5H'
|
||||
@@ -3693,7 +3700,7 @@ function getQuotaProgressLabel(label: string): string {
|
||||
}
|
||||
|
||||
function getQuotaProgressCountdown(item: QuotaProgressItem) {
|
||||
if (!['5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
|
||||
if (!['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
|
||||
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
|
||||
return getCodexResetCountdown(
|
||||
item.resetAtSeconds,
|
||||
@@ -3747,14 +3754,19 @@ function getQuotaLabelOrder(label: string): number {
|
||||
if (label === 'Expert') return 2
|
||||
if (label === 'Heavy') return 3
|
||||
if (label === 'Grok 4.3') return 4
|
||||
if (label === '5H') return 0
|
||||
if (label === '周') return 1
|
||||
if (label === 'Spark5H') return 2
|
||||
if (label === 'Spark周') return 3
|
||||
if (label === '剩余') return 4
|
||||
if (label === '最低') return 5
|
||||
if (label === '生图') return 6
|
||||
return 10
|
||||
if (label === '日') return 5
|
||||
if (label === '5H') return 6
|
||||
if (label === '周') return 7
|
||||
if (label === 'Spark5H') return 8
|
||||
if (label === 'Spark周') return 9
|
||||
if (label === 'Prompt') return 10
|
||||
if (label === 'Flex') return 11
|
||||
if (label === '剩余') return 12
|
||||
if (label === '最低') return 13
|
||||
if (label === '生图') return 14
|
||||
if (label === '速率') return 15
|
||||
if (label === '模型') return 16
|
||||
return 20
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
@@ -3998,6 +4010,57 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
}]
|
||||
}
|
||||
|
||||
if (providerType === 'windsurf') {
|
||||
const items: QuotaProgressItem[] = []
|
||||
for (const [label, code] of [
|
||||
['日', 'daily'],
|
||||
['周', 'weekly'],
|
||||
['Prompt', 'prompt'],
|
||||
['Flex', 'flex'],
|
||||
] as const) {
|
||||
const window = getQuotaSnapshotWindow(quota, code)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) continue
|
||||
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
|
||||
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
: typeof window?.remaining_value === 'number' && typeof window?.limit_value === 'number'
|
||||
? `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
: undefined
|
||||
items.push({
|
||||
label,
|
||||
remainingPercent,
|
||||
detail,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
|
||||
const rateLimitWindow = getQuotaSnapshotWindow(quota, 'rate_limit')
|
||||
if (rateLimitWindow) {
|
||||
items.push({
|
||||
label: '速率',
|
||||
remainingPercent: rateLimitWindow.is_exhausted ? 0 : 100,
|
||||
resetAtSeconds: normalizeUnixSeconds(rateLimitWindow.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(rateLimitWindow.reset_seconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof quota.allowed_models_count === 'number' && Number.isFinite(quota.allowed_models_count)) {
|
||||
items.push({
|
||||
label: '模型',
|
||||
remainingPercent: 100,
|
||||
detail: `${quota.allowed_models_count} 个`,
|
||||
resetAtSeconds: null,
|
||||
resetSeconds: null,
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
if (providerType === 'antigravity') {
|
||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (windows.length === 0) return []
|
||||
|
||||
Reference in New Issue
Block a user