diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 9c0a0fc96..f16375fad 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -561,12 +561,19 @@ impl RequestForwarder { // 检查是否需要格式转换 let needs_transform = adapter.needs_transform(provider); - let effective_endpoint = - if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { + // 确定有效端点: + // - 如果需要转换且是 Claude 的 /v1/messages 端点,改写为 /v1/chat/completions + // - 但如果 base_url 已包含 chat/completions,则不再自动添加 + let effective_endpoint = if needs_transform && adapter.name() == "Claude" { + let base_has_chat_completions = base_url.contains("chat/completions"); + if endpoint == "/v1/messages" && !base_has_chat_completions { "/v1/chat/completions" } else { endpoint - }; + } + } else { + endpoint + }; // 使用适配器构建 URL let url = adapter.build_url(&base_url, effective_endpoint); diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 04d4ec6ff..ea71790f3 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -23,10 +23,16 @@ impl ClaudeAdapter { /// 获取供应商类型 /// /// 根据 base_url 和 auth_mode 检测具体的供应商类型: + /// - ChatCompletions: chat_completions_mode 为 true(优先级最高) /// - OpenRouter: base_url 包含 openrouter.ai /// - ClaudeAuth: auth_mode 为 bearer_only /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { + // 检测 ChatCompletions 模式(优先级最高) + if self.is_chat_completions_mode(provider) { + return ProviderType::ChatCompletions; + } + // 检测 OpenRouter if self.is_openrouter(provider) { return ProviderType::OpenRouter; @@ -48,6 +54,20 @@ impl ClaudeAdapter { false } + /// 检测是否启用 ChatCompletions 兼容模式 + fn is_chat_completions_mode(&self, provider: &Provider) -> bool { + let raw = provider.settings_config.get("chat_completions_mode"); + match raw { + Some(serde_json::Value::Bool(enabled)) => *enabled, + Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0, + Some(serde_json::Value::String(value)) => { + let normalized = value.trim().to_lowercase(); + normalized == "true" || normalized == "1" + } + _ => false, + } + } + /// 检测 OpenRouter 是否启用兼容模式 fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool { if !self.is_openrouter(provider) { @@ -253,13 +273,9 @@ impl ProviderAdapter for ClaudeAdapter { } } - fn needs_transform(&self, _provider: &Provider) -> bool { - // NOTE: - // OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用 - // Anthropic ↔ OpenAI 的格式转换。 - // - // 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行: - self.is_openrouter_compat_enabled(_provider) + fn needs_transform(&self, provider: &Provider) -> bool { + // ChatCompletions 模式或 OpenRouter 兼容模式都需要转换 + self.is_chat_completions_mode(provider) || self.is_openrouter_compat_enabled(provider) } fn transform_request( diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 61be1087e..8621b9954 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -50,6 +50,8 @@ pub enum ProviderType { GeminiCli, /// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用) OpenRouter, + /// ChatCompletions 兼容模式(Anthropic ↔ OpenAI 格式转换,不限制上游地址) + ChatCompletions, } impl ProviderType { @@ -75,6 +77,7 @@ impl ProviderType { "https://generativelanguage.googleapis.com" } ProviderType::OpenRouter => "https://openrouter.ai/api", + ProviderType::ChatCompletions => "https://api.openai.com", } } @@ -148,6 +151,7 @@ impl ProviderType { ProviderType::Gemini => "gemini", ProviderType::GeminiCli => "gemini_cli", ProviderType::OpenRouter => "openrouter", + ProviderType::ChatCompletions => "chat_completions", } } } @@ -169,6 +173,9 @@ impl std::str::FromStr for ProviderType { "gemini" => Ok(ProviderType::Gemini), "gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli), "openrouter" => Ok(ProviderType::OpenRouter), + "chat_completions" | "chat-completions" | "chatcompletions" => { + Ok(ProviderType::ChatCompletions) + } _ => Err(format!("Invalid provider type: {s}")), } } @@ -191,9 +198,10 @@ pub fn get_adapter(app_type: &AppType) -> Box { #[allow(dead_code)] pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box { match provider_type { - ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => { - Box::new(ClaudeAdapter::new()) - } + ProviderType::Claude + | ProviderType::ClaudeAuth + | ProviderType::OpenRouter + | ProviderType::ChatCompletions => Box::new(ClaudeAdapter::new()), ProviderType::Codex => Box::new(CodexAdapter::new()), ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()), } diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index a3fd590c2..5f111aaef 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -115,16 +115,22 @@ pub fn create_anthropic_sse_stream( if let Some(choice) = chunk.choices.first() { if !has_sent_message_start { + // 构建完整的 message_start 事件,与原生 Anthropic 格式一致 let event = json!({ "type": "message_start", "message": { "id": message_id.clone().unwrap_or_default(), "type": "message", "role": "assistant", + "content": [], "model": current_model.clone().unwrap_or_default(), + "stop_reason": null, + "stop_sequence": null, "usage": { - "input_tokens": 0, - "output_tokens": 0 + "input_tokens": chunk.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(1), + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 } } }); @@ -272,18 +278,17 @@ pub fn create_anthropic_sse_stream( } let stop_reason = map_stop_reason(Some(finish_reason)); - // 构建 usage 信息,包含 input_tokens 和 output_tokens - let usage_json = chunk.usage.as_ref().map(|u| json!({ - "input_tokens": u.prompt_tokens, - "output_tokens": u.completion_tokens - })); + // 构建 usage 信息,Anthropic 格式只需要 output_tokens + let output_tokens = chunk.usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0); let event = json!({ "type": "message_delta", "delta": { "stop_reason": stop_reason, "stop_sequence": null }, - "usage": usage_json + "usage": { + "output_tokens": output_tokens + } }); let sse_data = format!("event: message_delta\ndata: {}\n\n", serde_json::to_string(&event).unwrap_or_default()); diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 5eda7f8d4..d42c2569b 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -430,6 +430,20 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "ChatCompletions", + websiteUrl: "", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "", + ANTHROPIC_AUTH_TOKEN: "", + }, + chat_completions_mode: true, + }, + category: "third_party", + icon: "openai", + iconColor: "#10A37F", + }, { name: "Xiaomi MiMo", websiteUrl: "https://platform.xiaomimimo.com",