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 列表",