Merge branch 'main' into feat/gemini-proxy-integration

# Conflicts:
#	src-tauri/src/proxy/providers/claude.rs
#	src-tauri/src/proxy/sse.rs
#	src-tauri/src/services/stream_check.rs
This commit is contained in:
YoVinchen
2026-04-10 09:08:39 +08:00
134 changed files with 12934 additions and 1095 deletions
+616 -18
View File
@@ -199,6 +199,14 @@ impl StreamCheckService {
claude_api_format_override: Option<String>,
) -> Result<StreamCheckResult, AppError> {
let start = Instant::now();
// OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同
// baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api`
// 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。
if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) {
return Self::check_once_without_adapter(app_type, provider, config, start).await;
}
let adapter = get_adapter(app_type);
let base_url = match base_url_override {
@@ -231,6 +239,7 @@ impl StreamCheckService {
request_timeout,
provider,
claude_api_format_override.as_deref(),
None,
)
.await
}
@@ -254,24 +263,13 @@ impl StreamCheckService {
&model_to_test,
test_prompt,
request_timeout,
None,
)
.await
}
AppType::OpenCode => {
// OpenCode doesn't support stream check yet
return Err(AppError::localized(
"opencode_no_stream_check",
"OpenCode 暂不支持健康检查",
"OpenCode does not support health check yet",
));
}
AppType::OpenClaw => {
// OpenClaw doesn't support stream check yet
return Err(AppError::localized(
"openclaw_no_stream_check",
"OpenClaw 暂不支持健康检查",
"OpenClaw does not support health check yet",
));
AppType::OpenCode | AppType::OpenClaw => {
// Already handled via early dispatch above
unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理")
}
};
@@ -313,6 +311,10 @@ impl StreamCheckService {
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
/// - "openai_responses": OpenAI Responses API (/v1/responses)
/// - "gemini_native": Gemini Native streamGenerateContent
///
/// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw
/// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers`
/// 读取),在所有内置 header 之后追加,用于覆盖或补充(例如自定义 User-Agent)。
#[allow(clippy::too_many_arguments)]
async fn check_claude_stream(
client: &Client,
@@ -323,6 +325,7 @@ impl StreamCheckService {
timeout: std::time::Duration,
provider: &Provider,
claude_api_format_override: Option<&str>,
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
@@ -367,8 +370,16 @@ impl StreamCheckService {
"messages": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记,
// 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
let body = if is_openai_responses {
anthropic_to_responses(anthropic_body, Some(&provider.id))
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_gemini_native {
anthropic_to_gemini(anthropic_body)
@@ -475,6 +486,15 @@ impl StreamCheckService {
.header("connection", "keep-alive");
}
// 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent
if let Some(headers) = extra_headers {
for (key, value) in headers {
if let Some(v) = value.as_str() {
request_builder = request_builder.header(key.as_str(), v);
}
}
}
let response = request_builder
.timeout(timeout)
.json(&body)
@@ -595,6 +615,7 @@ impl StreamCheckService {
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
@@ -614,11 +635,22 @@ impl StreamCheckService {
}]
});
let response = client
let mut request_builder = client
.post(&url)
.header("x-goog-api-key", &auth.api_key)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Accept", "text/event-stream");
// 供应商自定义 headers 最后追加
if let Some(headers) = extra_headers {
for (key, value) in headers {
if let Some(v) = value.as_str() {
request_builder = request_builder.header(key.as_str(), v);
}
}
}
let response = request_builder
.timeout(timeout)
.json(&body)
.send()
@@ -643,6 +675,451 @@ impl StreamCheckService {
}
}
/// OpenCode / OpenClaw 的独立分发入口(绕过 `get_adapter`
///
/// 这两个应用的 `settings_config` 与 Claude/Codex/Gemini 完全不同:
/// - OpenClaw: `{ baseUrl, apiKey, api, models: [...] }``api` 字段标识协议
/// - OpenCode: `{ npm, options: { baseURL, apiKey }, models: {...} }``npm` 字段标识协议
///
/// 因此不能复用 `get_adapter`(会 fallback 到 CodexAdapter 而提取失败),
/// 改为独立解析 base_url/api_key/协议,再分发到现有的 check_*_stream 函数。
async fn check_once_without_adapter(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
start: Instant,
) -> Result<StreamCheckResult, AppError> {
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = crate::proxy::http_client::get_for_provider(proxy_config);
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::OpenClaw => {
Self::check_openclaw_stream(
&client,
provider,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::OpenCode => {
Self::check_opencode_stream(
&client,
provider,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
_ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"),
};
let response_time = start.elapsed().as_millis() as u64;
Ok(Self::build_stream_check_result(
result,
response_time,
config.degraded_threshold_ms,
))
}
/// 将 check_*_stream 的原始结果包装成 StreamCheckResult
///
/// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。
fn build_stream_check_result(
result: Result<(u16, String), AppError>,
response_time: u64,
degraded_threshold_ms: u64,
) -> StreamCheckResult {
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status_code, model)) => StreamCheckResult {
status: Self::determine_status(response_time, degraded_threshold_ms),
success: true,
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
tested_at,
retry_count: 0,
},
Err(e) => StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: String::new(),
tested_at,
retry_count: 0,
},
}
}
/// OpenClaw 流式检查分发器
///
/// 根据 `settings_config.api` 字段分发到对应协议的检查器。
/// 取值参见 `openclawApiProtocols` (前端 openclawProviderPresets.ts):
/// - `openai-completions` → check_claude_stream + api_format="openai_chat"
/// - `openai-responses` → check_claude_stream + api_format="openai_responses"
/// - `anthropic-messages` → check_claude_stream + api_format="anthropic" (ClaudeAuth 策略)
/// - `google-generative-ai` → check_gemini_stream (Google API Key 策略)
/// - `bedrock-converse-stream` → 不支持(需要 AWS SigV4 签名)
async fn check_openclaw_stream(
client: &Client,
provider: &Provider,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
// 自定义认证头(如 Longcat 的 `apikey` 头)不走标准 Bearer
// 具体头名由 OpenClaw 网关内部决定,cc-switch 无法准确构造,
// 因此直接返回友好错误而不是让用户看到一个误导性的 401。
if Self::openclaw_uses_auth_header(provider) {
return Err(AppError::localized(
"openclaw_auth_header_not_supported",
"该供应商使用自定义认证头,暂不支持流式健康检查。建议直接通过 OpenClaw 测试。",
"This provider uses a custom auth header; stream health check is not supported. Please test it directly via OpenClaw.",
));
}
let base_url = Self::extract_openclaw_base_url(provider)?;
let api_key = Self::extract_openclaw_api_key(provider)?;
let api = Self::extract_openclaw_protocol(provider);
let extra_headers = Self::extract_openclaw_headers(provider);
match api.as_deref() {
Some("openai-completions") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_chat"),
extra_headers,
)
.await
}
Some("openai-responses") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_responses"),
extra_headers,
)
.await
}
Some("anthropic-messages") => {
// 使用 ClaudeAuthBearer-only)以兼容 Claude 中转服务。
// 某些中转同时收到 Authorization 和 x-api-key 会报错,ClaudeAuth
// 策略保证只下发 Bearer。官方 Anthropic 也接受纯 Bearer。
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("anthropic"),
extra_headers,
)
.await
}
Some("google-generative-ai") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Google);
Self::check_gemini_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
extra_headers,
)
.await
}
Some("bedrock-converse-stream") => Err(AppError::localized(
"openclaw_bedrock_not_supported",
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenClaw 验证连通性。",
"AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenClaw.",
)),
Some(other) => Err(AppError::localized(
"openclaw_protocol_not_yet_supported",
format!("OpenClaw 暂不支持协议: {other}"),
format!("OpenClaw protocol not yet supported: {other}"),
)),
None => Err(AppError::localized(
"openclaw_protocol_missing",
"OpenClaw 供应商缺少 api 字段",
"OpenClaw provider is missing the `api` field",
)),
}
}
/// 判断 OpenClaw 供应商是否使用自定义认证头(`authHeader: true`
fn openclaw_uses_auth_header(provider: &Provider) -> bool {
provider
.settings_config
.get("authHeader")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// 提取 OpenClaw 供应商的自定义 headers(来自 `settings_config.headers`
fn extract_openclaw_headers(
provider: &Provider,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
provider
.settings_config
.get("headers")
.and_then(|v| v.as_object())
.filter(|m| !m.is_empty())
}
fn extract_openclaw_base_url(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("baseUrl")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"openclaw_base_url_missing",
"OpenClaw 供应商缺少 baseUrl",
"OpenClaw provider is missing `baseUrl`",
)
})
}
fn extract_openclaw_api_key(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("apiKey")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"openclaw_api_key_missing",
"OpenClaw 供应商缺少 apiKey",
"OpenClaw provider is missing `apiKey`",
)
})
}
fn extract_openclaw_protocol(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("api")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// OpenCode 流式检查分发器
///
/// OpenCode 用 `npm` 字段(AI SDK 包名)隐式指定协议。映射关系参见
/// `opencodeNpmPackages` (前端 opencodeProviderPresets.ts):
/// - `@ai-sdk/openai-compatible` → check_claude_stream + api_format="openai_chat"
/// - `@ai-sdk/openai` → check_claude_stream + api_format="openai_responses"
/// - `@ai-sdk/anthropic` → check_claude_stream + api_format="anthropic"
/// - `@ai-sdk/google` → check_gemini_stream (Google API Key 策略)
/// - `@ai-sdk/amazon-bedrock` → 不支持(需要 AWS SigV4 签名)
///
/// URL/API Key 存放在 `settings_config.options.{baseURL,apiKey}`,注意
/// `baseURL` 大写 L(与 OpenClaw 的 `baseUrl` 首字母小写 u 不同)。
async fn check_opencode_stream(
client: &Client,
provider: &Provider,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let npm = Self::extract_opencode_npm(provider);
// 若用户未显式填 baseURL,则根据 npm 回退到 AI SDK 包自带的默认端点
let base_url = Self::resolve_opencode_base_url(provider, npm.as_deref())?;
let api_key = Self::extract_opencode_api_key(provider)?;
let extra_headers = Self::extract_opencode_headers(provider);
match npm.as_deref() {
Some("@ai-sdk/openai-compatible") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_chat"),
extra_headers,
)
.await
}
Some("@ai-sdk/openai") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_responses"),
extra_headers,
)
.await
}
Some("@ai-sdk/anthropic") => {
// 见 check_openclaw_stream 对 anthropic-messages 的注释:
// 用 ClaudeAuthBearer-only)兼容中转服务。
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("anthropic"),
extra_headers,
)
.await
}
Some("@ai-sdk/google") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Google);
Self::check_gemini_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
extra_headers,
)
.await
}
Some("@ai-sdk/amazon-bedrock") => Err(AppError::localized(
"opencode_bedrock_not_supported",
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenCode 验证连通性。",
"AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenCode.",
)),
Some(other) => Err(AppError::localized(
"opencode_npm_not_yet_supported",
format!("OpenCode 暂不支持 SDK 包: {other}"),
format!("OpenCode SDK package not yet supported: {other}"),
)),
None => Err(AppError::localized(
"opencode_npm_missing",
"OpenCode 供应商缺少 npm 字段",
"OpenCode provider is missing the `npm` field",
)),
}
}
/// 按 OpenCode 的实际 SDK 包特性确定 baseURL
/// - 用户显式填写的 `options.baseURL` 总是优先
/// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点
/// - `@ai-sdk/openai-compatible` 没有默认端点,必须显式填
///
/// 注意:这里的默认端点对应 AI SDK 包的行为(例如 `@ai-sdk/openai`
/// 自带 `/v1` 路径后缀),与 `proxy/providers/mod.rs` 里的
/// `ProviderType::default_endpoint()` 语义不同——后者是代理层的上游
/// 默认值,不带 `/v1`。两者维护的是不同系统的默认值,不能简单共享。
fn resolve_opencode_base_url(
provider: &Provider,
npm: Option<&str>,
) -> Result<String, AppError> {
if let Some(explicit) = Self::extract_opencode_base_url(provider) {
return Ok(explicit);
}
let fallback = match npm {
Some("@ai-sdk/openai") => Some("https://api.openai.com/v1"),
Some("@ai-sdk/anthropic") => Some("https://api.anthropic.com"),
Some("@ai-sdk/google") => Some("https://generativelanguage.googleapis.com"),
_ => None,
};
fallback.map(|s| s.to_string()).ok_or_else(|| {
AppError::localized(
"opencode_base_url_missing",
"OpenCode 供应商缺少 options.baseURL,且当前 SDK 包没有默认端点",
"OpenCode provider is missing `options.baseURL` and the SDK package has no default endpoint",
)
})
}
fn extract_opencode_base_url(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("options")
.and_then(|v| v.get("baseURL"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 提取 OpenCode 供应商的自定义 headers(来自 `settings_config.options.headers`
fn extract_opencode_headers(
provider: &Provider,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
provider
.settings_config
.get("options")
.and_then(|v| v.get("headers"))
.and_then(|v| v.as_object())
.filter(|m| !m.is_empty())
}
fn extract_opencode_api_key(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("options")
.and_then(|v| v.get("apiKey"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"opencode_api_key_missing",
"OpenCode 供应商缺少 options.apiKey",
"OpenCode provider is missing `options.apiKey`",
)
})
}
fn extract_opencode_npm(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("npm")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {
if latency_ms <= threshold {
HealthStatus::Operational
@@ -846,6 +1323,127 @@ impl StreamCheckService {
mod tests {
use super::*;
fn make_provider(settings_config: serde_json::Value) -> Provider {
Provider::with_id(
"test".to_string(),
"Test".to_string(),
settings_config,
None,
)
}
#[test]
fn test_openclaw_uses_auth_header_true() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://api.longcat.chat/v1",
"apiKey": "k",
"api": "openai-completions",
"authHeader": true,
}));
assert!(StreamCheckService::openclaw_uses_auth_header(&p));
}
#[test]
fn test_openclaw_uses_auth_header_default_false() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "k",
"api": "openai-completions",
}));
assert!(!StreamCheckService::openclaw_uses_auth_header(&p));
}
#[test]
fn test_resolve_opencode_base_url_explicit_wins() {
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai",
"options": { "baseURL": "https://proxy.local/v1", "apiKey": "k" },
"models": {},
}));
let resolved =
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap();
assert_eq!(resolved, "https://proxy.local/v1");
}
#[test]
fn test_resolve_opencode_base_url_falls_back_for_known_npm() {
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai",
"options": { "apiKey": "k" },
"models": {},
}));
let resolved =
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap();
assert_eq!(resolved, "https://api.openai.com/v1");
let p2 = make_provider(serde_json::json!({
"npm": "@ai-sdk/anthropic",
"options": { "apiKey": "k" },
"models": {},
}));
let resolved2 =
StreamCheckService::resolve_opencode_base_url(&p2, Some("@ai-sdk/anthropic")).unwrap();
assert_eq!(resolved2, "https://api.anthropic.com");
}
#[test]
fn test_resolve_opencode_base_url_errors_for_openai_compatible_without_url() {
// @ai-sdk/openai-compatible 没有默认端点,必须显式填
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai-compatible",
"options": { "apiKey": "k" },
"models": {},
}));
let result =
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai-compatible"));
assert!(result.is_err());
}
#[test]
fn test_extract_openclaw_headers_preserves_map() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://example.com/v1",
"apiKey": "k",
"api": "openai-completions",
"headers": { "User-Agent": "MyBot/1.0", "X-Trace": "abc" },
}));
let headers = StreamCheckService::extract_openclaw_headers(&p).unwrap();
assert_eq!(
headers.get("User-Agent").and_then(|v| v.as_str()),
Some("MyBot/1.0")
);
assert_eq!(headers.get("X-Trace").and_then(|v| v.as_str()), Some("abc"));
}
#[test]
fn test_extract_openclaw_headers_ignores_empty_map() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://example.com/v1",
"apiKey": "k",
"api": "openai-completions",
"headers": {},
}));
assert!(StreamCheckService::extract_openclaw_headers(&p).is_none());
}
#[test]
fn test_extract_opencode_headers_from_options() {
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://example.com/v1",
"apiKey": "k",
"headers": { "X-Custom": "yes" },
},
"models": {},
}));
let headers = StreamCheckService::extract_opencode_headers(&p).unwrap();
assert_eq!(
headers.get("X-Custom").and_then(|v| v.as_str()),
Some("yes")
);
}
#[test]
fn test_determine_status() {
assert_eq!(