Merge remote-tracking branch 'origin/main' into fix/gemini-cli-v1internal

# Conflicts:
#	apps/aether-gateway/src/ai_serving/transport.rs
#	apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs
#	apps/aether-gateway/src/handlers/shared/catalog.rs
#	crates/aether-admin/src/provider/quota.rs
#	crates/aether-model-fetch/src/strategy.rs
#	crates/aether-provider-pool/src/lib.rs
#	crates/aether-provider-pool/src/service.rs
#	crates/aether-provider-transport/src/provider_types.rs
#	frontend/src/features/providers/components/ProviderDetailDrawer.vue
#	frontend/src/utils/__tests__/providerKeyQuota.spec.ts
#	frontend/src/utils/providerKeyQuota.ts
#	frontend/src/views/admin/PoolManagement.vue
This commit is contained in:
Mas0nShi
2026-05-22 17:13:57 +08:00
220 changed files with 30324 additions and 2776 deletions
+128 -6
View File
@@ -17,8 +17,11 @@ 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_gemini_cli_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,
build_kiro_pool_quota_request, 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, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
@@ -26,6 +29,7 @@ pub use providers::{
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
GEMINI_CLI_USER_AGENT, 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,
@@ -70,7 +74,8 @@ mod tests {
"gemini_cli",
"grok",
"kiro",
"vertex_ai"
"vertex_ai",
"windsurf"
]
);
assert!(service
@@ -92,13 +97,15 @@ mod tests {
"codex",
"gemini_cli",
"grok",
"kiro"
"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("gemini_cli"));
assert!(service.supports_quota_refresh("windsurf"));
assert_eq!(
service.quota_refresh_unsupported_message("claude_code"),
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口"
@@ -150,6 +157,14 @@ mod tests {
spec.json_body.as_ref().and_then(|body| body.get("project")),
Some(&json!("project-1"))
);
assert_eq!(
spec.headers.get("user-agent").map(String::as_str),
Some(GEMINI_CLI_USER_AGENT)
);
assert!(spec
.json_body
.as_ref()
.is_some_and(|body| body.get("userAgent").is_none()));
assert_eq!(spec.client_api_format, "gemini:generate_content");
assert_eq!(spec.provider_api_format, "gemini_cli:retrieve_user_quota");
}
@@ -272,6 +287,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();
@@ -285,10 +404,13 @@ 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"])
);
}
@@ -69,7 +69,6 @@ pub fn build_gemini_cli_pool_quota_request(
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"project": project_id,
"userAgent": GEMINI_CLI_USER_AGENT,
})),
client_api_format: "gemini:generate_content".to_string(),
provider_api_format: "gemini_cli:retrieve_user_quota".to_string(),
@@ -6,6 +6,7 @@ pub mod gemini_cli;
pub mod grok;
pub mod kiro;
pub mod unsupported;
pub mod windsurf;
pub use antigravity::AntigravityProviderPoolAdapter;
pub use antigravity::{
@@ -37,3 +38,11 @@ pub use unsupported::{
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_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,
};
@@ -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(),
"[email protected]".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"
));
}
}
+3 -1
View File
@@ -13,7 +13,8 @@ use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
use crate::providers::{
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
VERTEX_AI_PROVIDER_POOL_ADAPTER,
};
#[derive(Clone)]
@@ -53,6 +54,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))
}