diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index 38d5b09d6..7afe21018 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -26,8 +26,22 @@ pub async fn stream_check_provider( .ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?; let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?; - let result = - StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?; + let claude_api_format_override = resolve_claude_api_format_override( + &app_type, + provider, + &config, + &copilot_state, + auth_override.as_ref(), + ) + .await?; + let result = StreamCheckService::check_with_retry( + &app_type, + provider, + &config, + auth_override, + claude_api_format_override, + ) + .await?; // 记录日志 let _ = @@ -73,19 +87,40 @@ pub async fn stream_check_all_providers( } let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?; - let result = - StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override) - .await - .unwrap_or_else(|e| StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message: e.to_string(), - response_time_ms: None, - http_status: None, - model_used: String::new(), - tested_at: chrono::Utc::now().timestamp(), - retry_count: 0, - }); + let claude_api_format_override = resolve_claude_api_format_override( + &app_type, + &provider, + &config, + &copilot_state, + auth_override.as_ref(), + ) + .await + .unwrap_or_else(|e| { + log::warn!( + "[StreamCheck] Failed to resolve Claude API format override for {}: {}", + provider.id, + e + ); + None + }); + let result = StreamCheckService::check_with_retry( + &app_type, + &provider, + &config, + auth_override, + claude_api_format_override, + ) + .await + .unwrap_or_else(|e| StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message: e.to_string(), + response_time_ms: None, + http_status: None, + model_used: String::new(), + tested_at: chrono::Utc::now().timestamp(), + retry_count: 0, + }); let _ = state .db @@ -154,3 +189,49 @@ async fn resolve_copilot_auth_override( crate::proxy::providers::AuthStrategy::GitHubCopilot, ))) } + +async fn resolve_claude_api_format_override( + app_type: &AppType, + provider: &crate::provider::Provider, + config: &StreamCheckConfig, + copilot_state: &State<'_, CopilotAuthState>, + auth_override: Option<&crate::proxy::providers::AuthInfo>, +) -> Result, AppError> { + if *app_type != AppType::Claude { + return Ok(None); + } + + let is_copilot = auth_override + .map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot) + .unwrap_or(false); + if !is_copilot { + return Ok(None); + } + + let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config); + let auth_manager = copilot_state.0.read().await; + let account_id = provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("github_copilot")); + + let vendor_result = match account_id.as_deref() { + Some(id) => auth_manager.get_model_vendor_for_account(id, &model_id).await, + None => auth_manager.get_model_vendor(&model_id).await, + }; + + let api_format = match vendor_result { + Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses", + Ok(Some(_)) | Ok(None) => "openai_chat", + Err(err) => { + log::warn!( + "[StreamCheck] Failed to resolve Copilot model vendor for {}: {}. Falling back to chat/completions", + model_id, + err + ); + "openai_chat" + } + }; + + Ok(Some(api_format.to_string())) +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index deeb9c481..82acea3d4 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -29,6 +29,7 @@ use tokio::sync::RwLock; pub struct ForwardResult { pub response: ProxyResponse, pub provider: Provider, + pub claude_api_format: Option, } pub struct ForwardError { @@ -179,7 +180,7 @@ impl RequestForwarder { ) .await { - Ok(response) => { + Ok((response, claude_api_format)) => { // 成功:记录成功并更新熔断器 let _ = self .router @@ -233,6 +234,7 @@ impl RequestForwarder { return Ok(ForwardResult { response, provider: provider.clone(), + claude_api_format, }); } Err(e) => { @@ -308,7 +310,7 @@ impl RequestForwarder { ) .await { - Ok(response) => { + Ok((response, claude_api_format)) => { log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); // 记录成功 let _ = self @@ -367,6 +369,7 @@ impl RequestForwarder { return Ok(ForwardResult { response, provider: provider.clone(), + claude_api_format, }); } Err(retry_err) => { @@ -506,7 +509,7 @@ impl RequestForwarder { ) .await { - Ok(response) => { + Ok((response, claude_api_format)) => { log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); let _ = self .router @@ -558,6 +561,7 @@ impl RequestForwarder { return Ok(ForwardResult { response, provider: provider.clone(), + claude_api_format, }); } Err(retry_err) => { @@ -745,15 +749,19 @@ impl RequestForwarder { // 使用适配器提取 base_url let base_url = adapter.extract_base_url(provider)?; - // 检查是否需要格式转换 - let needs_transform = adapter.needs_transform(provider); - let is_full_url = provider .meta .as_ref() .and_then(|meta| meta.is_full_url) .unwrap_or(false); + // 应用模型映射(独立于格式转换) + let (mapped_body, _original_model, _mapped_model) = + super::model_mapper::apply_model_mapping(body.clone(), provider); + + // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) + let mapped_body = normalize_thinking_type(mapped_body); + // 确定有效端点 // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) let is_copilot = provider @@ -762,9 +770,23 @@ impl RequestForwarder { .and_then(|m| m.provider_type.as_deref()) == Some("github_copilot") || base_url.contains("githubcopilot.com"); + let resolved_claude_api_format = if adapter.name() == "Claude" { + Some( + self.resolve_claude_api_format(provider, &mapped_body, is_copilot) + .await, + ) + } else { + None + }; + let needs_transform = match resolved_claude_api_format.as_deref() { + Some(api_format) => super::providers::claude_api_format_needs_transform(api_format), + None => adapter.needs_transform(provider), + }; let (effective_endpoint, passthrough_query) = if needs_transform && adapter.name() == "Claude" { - let api_format = super::providers::get_claude_api_format(provider); + let api_format = resolved_claude_api_format + .as_deref() + .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot) } else { ( @@ -781,16 +803,20 @@ impl RequestForwarder { adapter.build_url(&base_url, &effective_endpoint) }; - // 应用模型映射(独立于格式转换) - let (mapped_body, _original_model, _mapped_model) = - super::model_mapper::apply_model_mapping(body.clone(), provider); - - // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) - let mapped_body = normalize_thinking_type(mapped_body); - // 转换请求体(如果需要) let request_body = if needs_transform { - adapter.transform_request(mapped_body, provider)? + if adapter.name() == "Claude" { + let api_format = resolved_claude_api_format + .as_deref() + .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); + super::providers::transform_claude_request_for_api_format( + mapped_body, + provider, + api_format, + )? + } else { + adapter.transform_request(mapped_body, provider)? + } } else { mapped_body }; @@ -1140,7 +1166,7 @@ impl RequestForwarder { let status = response.status(); if status.is_success() { - Ok(response) + Ok((response, resolved_claude_api_format)) } else { let status_code = status.as_u16(); let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok(); @@ -1152,6 +1178,64 @@ impl RequestForwarder { } } + async fn resolve_claude_api_format( + &self, + provider: &Provider, + body: &Value, + is_copilot: bool, + ) -> String { + if !is_copilot { + return super::providers::get_claude_api_format(provider).to_string(); + } + + let model = body.get("model").and_then(|value| value.as_str()); + if let Some(model_id) = model { + if self + .is_copilot_openai_vendor_model(provider, model_id) + .await + { + return "openai_responses".to_string(); + } + } + + "openai_chat".to_string() + } + + async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool { + let Some(app_handle) = &self.app_handle else { + log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions"); + return false; + }; + + let copilot_state = app_handle.state::(); + let copilot_auth = copilot_state.0.read().await; + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("github_copilot")); + + let vendor_result = match account_id.as_deref() { + Some(id) => copilot_auth.get_model_vendor_for_account(id, model_id).await, + None => copilot_auth.get_model_vendor(model_id).await, + }; + + match vendor_result { + Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"), + Ok(None) => { + log::debug!( + "[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions" + ); + false + } + Err(err) => { + log::warn!( + "[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}" + ); + false + } + } + } + fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { match error { // 网络和上游错误:都应该尝试下一个供应商 @@ -1339,7 +1423,9 @@ fn rewrite_claude_transform_endpoint( return (endpoint.to_string(), passthrough_query); } - let target_path = if is_copilot { + let target_path = if is_copilot && api_format == "openai_responses" { + "/v1/responses" + } else if is_copilot { "/chat/completions" } else if api_format == "openai_responses" { "/v1/responses" @@ -1510,6 +1596,18 @@ mod tests { assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); } + #[test] + fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "openai_responses", + true, + ); + + assert_eq!(endpoint, "/v1/responses?x-id=1"); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + #[test] fn append_query_to_full_url_preserves_existing_query_string() { let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1")); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index b68e7d6b2..bfa537809 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -116,6 +116,11 @@ pub async fn handle_messages( }; ctx.provider = result.provider; + let api_format = result + .claude_api_format + .as_deref() + .unwrap_or_else(|| get_claude_api_format(&ctx.provider)) + .to_string(); let response = result.response; // 检查是否需要格式转换(OpenRouter 等中转服务) @@ -124,7 +129,8 @@ pub async fn handle_messages( // Claude 特有:格式转换处理 if needs_transform { - return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; + return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format) + .await; } // 通用响应处理(透传模式) @@ -140,9 +146,9 @@ async fn handle_claude_transform( state: &ProxyState, _original_body: &Value, is_stream: bool, + api_format: &str, ) -> Result { let status = response.status(); - let api_format = get_claude_api_format(&ctx.provider); if is_stream { // 根据 api_format 选择流式转换器 diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index f2f2d739f..8d59dc2a7 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -65,6 +65,30 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { } } +pub fn claude_api_format_needs_transform(api_format: &str) -> bool { + matches!(api_format, "openai_chat" | "openai_responses") +} + +pub fn transform_claude_request_for_api_format( + body: serde_json::Value, + provider: &Provider, + api_format: &str, +) -> Result { + let cache_key = provider + .meta + .as_ref() + .and_then(|m| m.prompt_cache_key.as_deref()) + .unwrap_or(&provider.id); + + match api_format { + "openai_responses" => { + super::transform_responses::anthropic_to_responses(body, Some(cache_key)) + } + "openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)), + _ => Ok(body), + } +} + /// Claude 适配器 pub struct ClaudeAdapter; @@ -380,19 +404,7 @@ impl ProviderAdapter for ClaudeAdapter { body: serde_json::Value, provider: &Provider, ) -> Result { - // Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id - let cache_key = provider - .meta - .as_ref() - .and_then(|m| m.prompt_cache_key.as_deref()) - .unwrap_or(&provider.id); - - match self.get_api_format(provider) { - "openai_responses" => { - super::transform_responses::anthropic_to_responses(body, Some(cache_key)) - } - _ => super::transform::anthropic_to_openai(body, Some(cache_key)), - } + transform_claude_request_for_api_format(body, provider, self.get_api_format(provider)) } fn transform_response(&self, body: serde_json::Value) -> Result { @@ -798,4 +810,25 @@ mod tests { // GitHub Copilot always needs transform assert!(adapter.needs_transform(&copilot)); } + + #[test] + fn test_transform_claude_request_for_api_format_responses() { + let provider = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" + } + })); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap(); + + assert_eq!(transformed["model"], "gpt-5.4"); + assert!(transformed.get("input").is_some()); + assert!(transformed.get("max_output_tokens").is_some()); + } } diff --git a/src-tauri/src/proxy/providers/copilot_auth.rs b/src-tauri/src/proxy/providers/copilot_auth.rs index 9fca0ff45..04f3f9f33 100644 --- a/src-tauri/src/proxy/providers/copilot_auth.rs +++ b/src-tauri/src/proxy/providers/copilot_auth.rs @@ -310,6 +310,8 @@ pub struct CopilotAuthManager { refresh_locks: Arc>>>>, /// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新) copilot_tokens: Arc>>, + /// Copilot Models 缓存(key = GitHub user ID,仅进程内复用) + copilot_models: Arc>>>, /// HTTP 客户端 http_client: Client, /// 存储路径 @@ -330,6 +332,7 @@ impl CopilotAuthManager { default_account_id: Arc::new(RwLock::new(None)), refresh_locks: Arc::new(RwLock::new(HashMap::new())), copilot_tokens: Arc::new(RwLock::new(HashMap::new())), + copilot_models: Arc::new(RwLock::new(HashMap::new())), http_client: Client::new(), storage_path, pending_migration: Arc::new(RwLock::new(None)), @@ -375,6 +378,10 @@ impl CopilotAuthManager { let mut tokens = self.copilot_tokens.write().await; tokens.remove(account_id); } + { + let mut models = self.copilot_models.write().await; + models.remove(account_id); + } { let mut refresh_locks = self.refresh_locks.write().await; refresh_locks.remove(account_id); @@ -625,6 +632,27 @@ impl CopilotAuthManager { pub async fn fetch_models_for_account( &self, account_id: &str, + ) -> Result, CopilotAuthError> { + self.ensure_migration_complete().await?; + + { + let models = self.copilot_models.read().await; + if let Some(cached) = models.get(account_id) { + return Ok(cached.clone()); + } + } + + let models = self.fetch_models_for_account_uncached(account_id).await?; + { + let mut cache = self.copilot_models.write().await; + cache.insert(account_id.to_string(), models.clone()); + } + Ok(models) + } + + async fn fetch_models_for_account_uncached( + &self, + account_id: &str, ) -> Result, CopilotAuthError> { let copilot_token = self.get_valid_token_for_account(account_id).await?; @@ -673,6 +701,18 @@ impl CopilotAuthManager { Ok(models) } + pub async fn get_model_vendor_for_account( + &self, + account_id: &str, + model_id: &str, + ) -> Result, CopilotAuthError> { + let models = self.fetch_models_for_account(account_id).await?; + Ok(models + .into_iter() + .find(|model| model.id == model_id) + .map(|model| model.vendor)) + } + /// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号) pub async fn fetch_models(&self) -> Result, CopilotAuthError> { match self.resolve_default_account_id().await { @@ -681,6 +721,16 @@ impl CopilotAuthManager { } } + pub async fn get_model_vendor( + &self, + model_id: &str, + ) -> Result, CopilotAuthError> { + match self.resolve_default_account_id().await { + Some(id) => self.get_model_vendor_for_account(&id, model_id).await, + None => Err(CopilotAuthError::GitHubTokenInvalid), + } + } + /// 获取指定账号的 Copilot 使用量信息 pub async fn fetch_usage_for_account( &self, @@ -1136,6 +1186,7 @@ impl CopilotAuthManager { #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; #[test] fn test_copilot_token_expiry() { @@ -1308,4 +1359,59 @@ mod tests { Some("67890".to_string()) ); } + + #[tokio::test] + async fn test_get_model_vendor_from_cache() { + let temp_dir = tempdir().unwrap(); + let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf()); + + { + let mut default_account_id = manager.default_account_id.write().await; + *default_account_id = Some("12345".to_string()); + } + { + let mut accounts = manager.accounts.write().await; + accounts.insert( + "12345".to_string(), + GitHubAccountData { + github_token: "gho_test".to_string(), + user: GitHubUser { + login: "alice".to_string(), + id: 12345, + avatar_url: None, + }, + authenticated_at: 1700000000, + }, + ); + } + { + let mut models = manager.copilot_models.write().await; + models.insert( + "12345".to_string(), + vec![ + CopilotModel { + id: "gpt-5.4".to_string(), + name: "GPT-5.4".to_string(), + vendor: "OpenAI".to_string(), + model_picker_enabled: true, + }, + CopilotModel { + id: "claude-sonnet-4".to_string(), + name: "Claude Sonnet 4".to_string(), + vendor: "Anthropic".to_string(), + model_picker_enabled: true, + }, + ], + ); + } + + let vendor = manager + .get_model_vendor_for_account("12345", "gpt-5.4") + .await + .unwrap(); + assert_eq!(vendor.as_deref(), Some("OpenAI")); + + let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap(); + assert_eq!(default_vendor.as_deref(), Some("Anthropic")); + } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index d3e646c56..646bec414 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -30,7 +30,10 @@ use serde::{Deserialize, Serialize}; // 公开导出 pub use adapter::ProviderAdapter; pub use auth::{AuthInfo, AuthStrategy}; -pub use claude::{get_claude_api_format, ClaudeAdapter}; +pub use claude::{ + claude_api_format_needs_transform, get_claude_api_format, + transform_claude_request_for_api_format, ClaudeAdapter, +}; pub use codex::CodexAdapter; pub use gemini::GeminiAdapter; diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index fc33de56e..7840b68fa 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -109,6 +109,7 @@ pub fn create_anthropic_sse_stream_from_responses = HashMap::new(); let mut open_indices: HashSet = HashSet::new(); let mut fallback_open_index: Option = None; + let mut current_text_index: Option = None; let mut tool_index_by_item_id: HashMap = HashMap::new(); let mut last_tool_index: Option = None; @@ -218,12 +219,18 @@ pub fn create_anthropic_sse_stream_from_responses { if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { - let index = resolve_content_index( - &data, - &mut next_content_index, - &mut index_by_key, - &mut fallback_open_index, - ); + let index = if let Some(index) = current_text_index { + index + } else { + let index = resolve_content_index( + &data, + &mut next_content_index, + &mut index_by_key, + &mut fallback_open_index, + ); + current_text_index = Some(index); + index + }; if !open_indices.contains(&index) { let start_event = json!({ @@ -290,12 +303,18 @@ pub fn create_anthropic_sse_stream_from_responses { if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { - let index = resolve_content_index( - &data, - &mut next_content_index, - &mut index_by_key, - &mut fallback_open_index, - ); + let index = if let Some(index) = current_text_index { + index + } else { + let index = resolve_content_index( + &data, + &mut next_content_index, + &mut index_by_key, + &mut fallback_open_index, + ); + current_text_index = Some(index); + index + }; if !open_indices.contains(&index) { let start_event = json!({ @@ -329,29 +348,7 @@ pub fn create_anthropic_sse_stream_from_responses { - let key = content_part_key(&data); - let index = if let Some(k) = key { - index_by_key.get(&k).copied() - } else { - fallback_open_index - }; - if let Some(index) = index { - if !open_indices.remove(&index) { - continue; - } - let event = json!({ - "type": "content_block_stop", - "index": index - }); - let sse = format!("event: content_block_stop\ndata: {}\n\n", - serde_json::to_string(&event).unwrap_or_default()); - yield Ok(Bytes::from(sse)); - if fallback_open_index == Some(index) { - fallback_open_index = None; - } - } - } + "response.content_part.done" => {} // ================================================ // response.output_item.added (function_call) → content_block_start (tool_use) @@ -361,6 +358,20 @@ pub fn create_anthropic_sse_stream_from_responses { - let key = content_part_key(&data); - let index = if let Some(k) = key { - index_by_key.get(&k).copied() - } else { - fallback_open_index - }; + let index = current_text_index.take().or_else(|| { + let key = content_part_key(&data); + if let Some(k) = key { + index_by_key.get(&k).copied() + } else { + fallback_open_index + } + }); if let Some(index) = index { if !open_indices.remove(&index) { continue; @@ -553,6 +566,20 @@ pub fn create_anthropic_sse_stream_from_responses { + if let Some(index) = current_text_index.take() { + if open_indices.remove(&index) { + let stop_event = json!({ + "type": "content_block_stop", + "index": index + }); + let stop_sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&stop_event).unwrap_or_default()); + yield Ok(Bytes::from(stop_sse)); + } + if fallback_open_index == Some(index) { + fallback_open_index = None; + } + } + } + "response.output_item.done" | "response.in_progress" => {} // Any other unknown/future events — silently skip. @@ -908,4 +950,75 @@ mod tests { ); assert!(merged.contains("\"stop_reason\":\"end_turn\"")); } + + #[tokio::test] + async fn test_streaming_text_parts_are_merged_into_one_text_block() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_merge\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n", + "event: response.content_part.added\n", + "data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\",\"output_index\":0,\"content_index\":0}\n\n", + "event: response.content_part.done\n", + "data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n", + "event: response.content_part.added\n", + "data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":1}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\",\"output_index\":0,\"content_index\":1}\n\n", + "event: response.content_part.done\n", + "data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":1}\n\n", + "event: response.output_text.done\n", + "data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":1}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n" + ); + + let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]); + let converted = create_anthropic_sse_stream_from_responses(upstream); + let chunks: Vec<_> = converted.collect().await; + let events: Vec = chunks + .into_iter() + .flat_map(|chunk| { + let bytes = chunk.unwrap(); + let text = String::from_utf8_lossy(bytes.as_ref()).to_string(); + text.split("\n\n") + .filter_map(|block| { + block.lines().find_map(|line| { + strip_sse_field(line, "data") + .and_then(|payload| serde_json::from_str::(payload).ok()) + }) + }) + .collect::>() + }) + .collect(); + + let text_starts = events + .iter() + .filter(|event| { + event.get("type").and_then(|v| v.as_str()) == Some("content_block_start") + && event.pointer("/content_block/type").and_then(|v| v.as_str()) == Some("text") + }) + .count(); + let text_stops = events + .iter() + .filter(|event| event.get("type").and_then(|v| v.as_str()) == Some("content_block_stop")) + .count(); + let text_deltas: Vec = events + .iter() + .filter(|event| { + event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta") + && event.pointer("/delta/type").and_then(|v| v.as_str()) == Some("text_delta") + }) + .filter_map(|event| { + event.pointer("/delta/text") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + .collect(); + + assert_eq!(text_starts, 1); + assert_eq!(text_stops, 1); + assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]); + } } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 38889785d..e4ec20cbc 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -88,6 +88,7 @@ impl StreamCheckService { provider: &Provider, config: &StreamCheckConfig, auth_override: Option, + claude_api_format_override: Option, ) -> Result { // 合并供应商单独配置和全局配置 let effective_config = Self::merge_provider_config(provider, config); @@ -95,8 +96,14 @@ impl StreamCheckService { for attempt in 0..=effective_config.max_retries { let result = - Self::check_once(app_type, provider, &effective_config, auth_override.clone()) - .await; + Self::check_once( + app_type, + provider, + &effective_config, + auth_override.clone(), + claude_api_format_override.clone(), + ) + .await; match &result { Ok(r) if r.success => { @@ -185,6 +192,7 @@ impl StreamCheckService { provider: &Provider, config: &StreamCheckConfig, auth_override: Option, + claude_api_format_override: Option, ) -> Result { let start = Instant::now(); let adapter = get_adapter(app_type); @@ -215,6 +223,7 @@ impl StreamCheckService { test_prompt, request_timeout, provider, + claude_api_format_override.as_deref(), ) .await } @@ -303,6 +312,7 @@ impl StreamCheckService { test_prompt: &str, timeout: std::time::Duration, provider: &Provider, + claude_api_format_override: Option<&str>, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot; @@ -320,19 +330,28 @@ impl StreamCheckService { }) .unwrap_or("anthropic"); + let effective_api_format = claude_api_format_override.unwrap_or(api_format); + let is_full_url = provider .meta .as_ref() .and_then(|meta| meta.is_full_url) .unwrap_or(false); - let is_openai_chat = is_github_copilot || api_format == "openai_chat"; - let is_openai_responses = !is_github_copilot && api_format == "openai_responses"; - let url = Self::resolve_claude_stream_url(base, auth.strategy, api_format, is_full_url); + let is_openai_chat = effective_api_format == "openai_chat"; + let is_openai_responses = effective_api_format == "openai_responses"; + let url = Self::resolve_claude_stream_url( + base, + auth.strategy, + effective_api_format, + is_full_url, + ); + + let max_tokens = if is_openai_responses { 16 } else { 1 }; // Build from Anthropic-native shape first, then convert for configured targets. let anthropic_body = json!({ "model": model, - "max_tokens": 1, + "max_tokens": max_tokens, "messages": [{ "role": "user", "content": test_prompt }], "stream": true }); @@ -730,7 +749,9 @@ impl StreamCheckService { let base = base_url.trim_end_matches('/'); let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot; - if is_github_copilot { + if is_github_copilot && api_format == "openai_responses" { + format!("{base}/v1/responses") + } else if is_github_copilot { format!("{base}/chat/completions") } else if api_format == "openai_responses" { if base.ends_with("/v1") { @@ -764,6 +785,15 @@ impl StreamCheckService { vec![format!("{base}/responses"), format!("{base}/v1/responses")] } } + + pub(crate) fn resolve_effective_test_model( + app_type: &AppType, + provider: &Provider, + config: &StreamCheckConfig, + ) -> String { + let effective_config = Self::merge_provider_config(provider, config); + Self::resolve_test_model(app_type, provider, &effective_config) + } } #[cfg(test)] @@ -884,13 +914,25 @@ mod tests { let url = StreamCheckService::resolve_claude_stream_url( "https://api.githubcopilot.com", AuthStrategy::GitHubCopilot, - "anthropic", + "openai_chat", false, ); assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); } + #[test] + fn test_resolve_claude_stream_url_for_github_copilot_responses() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://api.githubcopilot.com", + AuthStrategy::GitHubCopilot, + "openai_responses", + false, + ); + + assert_eq!(url, "https://api.githubcopilot.com/v1/responses"); + } + #[test] fn test_resolve_claude_stream_url_for_openai_chat() { let url = StreamCheckService::resolve_claude_stream_url( diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index f88804a6a..810de2760 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ websiteUrl: "https://www.kimi.com/coding/docs/", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { - npm: "@ai-sdk/openai-compatible", + npm: "@ai-sdk/anthropic", name: "Kimi For Coding", options: { - baseURL: "https://api.kimi.com/v1", + baseURL: "https://api.kimi.com/coding/v1", apiKey: "", setCacheKey: true, }, @@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ templateValues: { baseURL: { label: "Base URL", - placeholder: "https://api.kimi.com/v1", - defaultValue: "https://api.kimi.com/v1", + placeholder: "https://api.kimi.com/coding/v1", + defaultValue: "https://api.kimi.com/coding/v1", editorValue: "", }, apiKey: { diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 8c14a0531..344616843 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -139,10 +139,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 切换供应商 const switchProvider = useCallback( async (provider: Provider) => { + const isCopilotProvider = + activeApp === "claude" && + provider.meta?.providerType === "github_copilot"; + // Determine why this provider requires the proxy let proxyRequiredReason: string | null = null; if (!isProxyRunning && provider.category !== "official") { - if ( + if (isCopilotProvider) { + proxyRequiredReason = t("notifications.proxyReasonCopilot", { + defaultValue: "使用 GitHub Copilot 作为 Claude 供应商", + }); + } else if ( provider.meta?.apiFormat === "openai_chat" && activeApp === "claude" ) { @@ -192,8 +200,27 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { ); } - // OpenCode/OpenClaw: show "added to config" message instead of "switched" - { + // 根据供应商类型显示不同的成功提示 + if ( + activeApp === "claude" && + provider.category !== "official" && + (isCopilotProvider || + provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "openai_responses") + ) { + // OpenAI format provider: show proxy hint + toast.info( + isCopilotProvider + ? t("notifications.copilotProxyHint") + : t("notifications.openAIFormatHint"), + { + duration: 5000, + closeButton: true, + }, + ); + } else { + // 普通供应商:显示切换成功 + // OpenCode/OpenClaw: show "added to config" message instead of "switched" const isMultiProviderApp = activeApp === "opencode" || activeApp === "openclaw"; const messageKey = isMultiProviderApp diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2f5ca3046..ca639040d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -175,9 +175,12 @@ "settingsSaved": "Settings saved", "settingsSaveFailed": "Failed to save settings: {{error}}", "proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.", + "proxyReasonCopilot": "uses GitHub Copilot as a Claude provider", "proxyReasonOpenAIChat": "uses OpenAI Chat API format", "proxyReasonOpenAIResponses": "uses OpenAI Responses API format", "proxyReasonFullUrl": "has full URL connection mode enabled", + "openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled", + "copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.", "openLinkFailed": "Failed to open link", "openclawModelsRegistered": "Models have been registered to /model list", "openclawDefaultModelSet": "Set as default model", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 654f02190..76f4808a9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -175,9 +175,12 @@ "settingsSaved": "設定を保存しました", "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください", + "proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており", "proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており", "proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており", "proxyReasonFullUrl": "完全 URL 接続モードが有効になっており", + "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です", + "copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。", "openLinkFailed": "リンクを開けませんでした", "openclawModelsRegistered": "モデルが /model リストに登録されました", "openclawDefaultModelSet": "デフォルトモデルに設定しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1eece7581..1b80ae690 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -175,9 +175,12 @@ "settingsSaved": "设置已保存", "settingsSaveFailed": "保存设置失败:{{error}}", "proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理", + "proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商", "proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式", "proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式", "proxyReasonFullUrl": "开启了完整 URL 连接模式", + "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", + "copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。", "openLinkFailed": "链接打开失败", "openclawModelsRegistered": "模型已注册到 /model 列表", "openclawDefaultModelSet": "已设为默认模型", diff --git a/tests/config/opencodeProviderPresets.test.ts b/tests/config/opencodeProviderPresets.test.ts index 0a57869a0..c54cea5f4 100644 --- a/tests/config/opencodeProviderPresets.test.ts +++ b/tests/config/opencodeProviderPresets.test.ts @@ -65,4 +65,19 @@ describe("AWS Bedrock OpenCode Provider Presets", () => { modelIds.some((id) => id.includes("anthropic.claude")), ).toBe(true); }); + + it("Kimi For Coding preset should use Anthropic with the coding endpoint", () => { + const kimiForCodingPreset = opencodeProviderPresets.find( + (p) => p.name === "Kimi For Coding", + ); + + expect(kimiForCodingPreset).toBeDefined(); + expect(kimiForCodingPreset!.settingsConfig.npm).toBe("@ai-sdk/anthropic"); + expect(kimiForCodingPreset!.settingsConfig.options?.baseURL).toBe( + "https://api.kimi.com/coding/v1", + ); + expect(kimiForCodingPreset!.templateValues?.baseURL.defaultValue).toBe( + "https://api.kimi.com/coding/v1", + ); + }); });