From 8d5f72757ed57bc3bd60a7bdf1c094670e479d74 Mon Sep 17 00:00:00 2001 From: Zhou Mengze Date: Sun, 29 Mar 2026 10:29:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Copilot=20?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=20Claude=20=E6=97=B6=20OpenAI=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9A=84=20Responses=20=E5=88=86=E6=B5=81=20(#1735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: route copilot claude openai models to responses * fix(i18n): add copilotProxyHint translation key for all locales The copilotProxyHint message was using inline defaultValue with Chinese text, which would show Chinese to English and Japanese users. Added proper translation keys in zh/en/ja locale files and removed the hardcoded defaultValue fallback. --------- Co-authored-by: Jason --- src-tauri/src/commands/stream_check.rs | 111 +++++++-- src-tauri/src/proxy/forwarder.rs | 134 +++++++++-- src-tauri/src/proxy/handlers.rs | 10 +- src-tauri/src/proxy/providers/claude.rs | 59 +++-- src-tauri/src/proxy/providers/copilot_auth.rs | 106 +++++++++ src-tauri/src/proxy/providers/mod.rs | 5 +- .../proxy/providers/streaming_responses.rs | 211 ++++++++++++++---- src-tauri/src/services/stream_check.rs | 58 ++++- src/hooks/useProviderActions.ts | 20 +- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + 12 files changed, 602 insertions(+), 115 deletions(-) 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 56963e6ce..ad14ae020 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -81,6 +81,7 @@ const HEADER_BLACKLIST: &[&str] = &[ pub struct ForwardResult { pub response: Response, pub provider: Provider, + pub claude_api_format: Option, } pub struct ForwardError { @@ -229,7 +230,7 @@ impl RequestForwarder { ) .await { - Ok(response) => { + Ok((response, claude_api_format)) => { // 成功:记录成功并更新熔断器 let _ = self .router @@ -283,6 +284,7 @@ impl RequestForwarder { return Ok(ForwardResult { response, provider: provider.clone(), + claude_api_format, }); } Err(e) => { @@ -357,7 +359,7 @@ impl RequestForwarder { ) .await { - Ok(response) => { + Ok((response, claude_api_format)) => { log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); // 记录成功 let _ = self @@ -416,6 +418,7 @@ impl RequestForwarder { return Ok(ForwardResult { response, provider: provider.clone(), + claude_api_format, }); } Err(retry_err) => { @@ -554,7 +557,7 @@ impl RequestForwarder { ) .await { - Ok(response) => { + Ok((response, claude_api_format)) => { log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); let _ = self .router @@ -606,6 +609,7 @@ impl RequestForwarder { return Ok(ForwardResult { response, provider: provider.clone(), + claude_api_format, }); } Err(retry_err) => { @@ -788,19 +792,23 @@ impl RequestForwarder { body: &Value, headers: &axum::http::HeaderMap, adapter: &dyn ProviderAdapter, - ) -> Result { + ) -> Result<(Response, Option), ProxyError> { // 使用适配器提取 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 @@ -809,9 +817,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 { ( @@ -828,16 +850,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 }; @@ -1019,7 +1045,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 = response.text().await.ok(); @@ -1031,6 +1057,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 { // 网络和上游错误:都应该尝试下一个供应商 @@ -1218,7 +1302,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" @@ -1388,6 +1474,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 7e13d5150..2210df314 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -101,6 +101,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 等中转服务) @@ -109,7 +114,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; } // 通用响应处理(透传模式) @@ -125,9 +131,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 9a8bcb5b2..5cecdf7ce 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -66,6 +66,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; @@ -363,19 +387,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 { @@ -781,4 +793,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 589c57426..11ee38789 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); @@ -629,6 +636,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?; @@ -678,6 +706,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 { @@ -686,6 +726,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, @@ -1143,6 +1193,7 @@ impl CopilotAuthManager { #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; #[test] fn test_copilot_token_expiry() { @@ -1315,4 +1366,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 6f97b7283..6b7e250f3 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( let mut index_by_key: HashMap = 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(part) = data.get("part") { let part_type = part.get("type").and_then(|t| t.as_str()); if matches!(part_type, Some("output_text") | Some("refusal")) { - 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) { continue; @@ -250,12 +257,18 @@ pub fn create_anthropic_sse_stream_from_responses( // ================================================ "response.output_text.delta" => { 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( // ================================================ "response.refusal.delta" => { 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( // ================================================ // response.content_part.done → content_block_stop // ================================================ - "response.content_part.done" => { - 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 item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); if item_type == "function_call" { has_tool_use = true; + 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; + } + } // 确保 message_start 已发送 if !has_sent_message_start { let start_event = json!({ @@ -521,12 +532,14 @@ pub fn create_anthropic_sse_stream_from_responses( // response.refusal.done → content_block_stop // ================================================ "response.refusal.done" => { - 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( .or_else(|| data.get("text")) .and_then(|d| d.as_str()) { + 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; + } + } let index = resolve_content_index( &data, &mut next_content_index, @@ -674,8 +701,23 @@ pub fn create_anthropic_sse_stream_from_responses( // Lifecycle events that don't need Anthropic counterparts. // Listed explicitly so new events trigger a match-completeness review. - "response.output_text.done" - | "response.output_item.done" + "response.output_text.done" => { + 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. @@ -902,4 +944,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 e8a7da041..504faff8b 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 }); @@ -724,7 +743,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") { @@ -758,6 +779,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)] @@ -878,13 +908,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/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 4f925d402..9df783c62 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -139,18 +139,20 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 切换供应商 const switchProvider = useCallback( async (provider: Provider) => { + const isCopilotProvider = + activeApp === "claude" && + provider.meta?.providerType === "github_copilot"; const requiresProxyForSwitch = !isProxyRunning && provider.category !== "official" && ((activeApp === "claude" && - (provider.meta?.isFullUrl || + (isCopilotProvider || + provider.meta?.isFullUrl || provider.meta?.apiFormat === "openai_chat" || provider.meta?.apiFormat === "openai_responses")) || (activeApp === "codex" && provider.meta?.isFullUrl)); - if ( - requiresProxyForSwitch - ) { + if (requiresProxyForSwitch) { toast.warning( t("notifications.proxyRequiredForSwitch", { defaultValue: "此供应商需要代理服务,请先启动代理", @@ -178,15 +180,15 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { if ( activeApp === "claude" && provider.category !== "official" && - (provider.meta?.apiFormat === "openai_chat" || + (isCopilotProvider || + provider.meta?.apiFormat === "openai_chat" || provider.meta?.apiFormat === "openai_responses") ) { // OpenAI format provider: show proxy hint toast.info( - t("notifications.openAIFormatHint", { - defaultValue: - "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", - }), + isCopilotProvider + ? t("notifications.copilotProxyHint") + : t("notifications.openAIFormatHint"), { duration: 5000, closeButton: true, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0a526bcf4..1ad40016c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -176,6 +176,7 @@ "settingsSaveFailed": "Failed to save settings: {{error}}", "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be 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.", "proxyRequiredForSwitch": "This provider requires the proxy service. Start the proxy first.", "openLinkFailed": "Failed to open link", "openclawModelsRegistered": "Models have been registered to /model list", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cd9804193..458a113ca 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -176,6 +176,7 @@ "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です", + "copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。", "proxyRequiredForSwitch": "このプロバイダーにはプロキシサービスが必要です。先にプロキシを起動してください", "openLinkFailed": "リンクを開けませんでした", "openclawModelsRegistered": "モデルが /model リストに登録されました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a1f063263..0886e18f0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -176,6 +176,7 @@ "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", + "copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。", "proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理", "openLinkFailed": "链接打开失败", "openclawModelsRegistered": "模型已注册到 /model 列表", From 3b33e6921bbb3ee6bd9301b64dfad33adc170314 Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:31:23 +0800 Subject: [PATCH 2/2] fix: correct opencode kimi-for-coding preset (#1738) --- src/config/opencodeProviderPresets.ts | 8 ++++---- tests/config/opencodeProviderPresets.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) 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/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", + ); + }); });