feat(provider): 原生接入 Windsurf provider

This commit is contained in:
Entropy.Xu
2026-05-18 15:23:08 +08:00
parent 923515ab28
commit 0226e14251
51 changed files with 7470 additions and 157 deletions

View File

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

View File

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

View File

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

View File

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