From 113512165d44ea477d16d08cda7506532f29c80c Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sun, 5 Apr 2026 17:12:35 +0800 Subject: [PATCH] fix(claude): add gemini_chat format for Gemini-compatible endpoints (#1899) --- src-tauri/src/provider.rs | 5 +- src-tauri/src/proxy/forwarder.rs | 14 ++++- src-tauri/src/proxy/providers/claude.rs | 54 ++++++++++++++++++- src-tauri/src/services/stream_check.rs | 24 ++++++++- .../providers/forms/ClaudeFormFields.tsx | 13 +++-- src/config/claudeProviderPresets.ts | 3 +- src/hooks/useProviderActions.ts | 8 +++ src/i18n/locales/en.json | 3 ++ src/i18n/locales/ja.json | 3 ++ src/i18n/locales/zh.json | 3 ++ src/types.ts | 12 +++-- 11 files changed, 128 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 67616853c..92fe37a02 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -268,6 +268,7 @@ pub struct ProviderMeta { /// Claude API 格式(仅 Claude 供应商使用) /// - "anthropic": 原生 Anthropic Messages API,直接透传 /// - "openai_chat": OpenAI Chat Completions 格式,需要转换 + /// - "gemini_chat": Gemini Chat 兼容格式,需要转换,但不注入 prompt_cache_key /// - "openai_responses": OpenAI Responses API 格式,需要转换 #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] pub api_format: Option, @@ -282,9 +283,9 @@ pub struct ProviderMeta { /// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径) #[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")] pub is_full_url: Option, - /// Prompt cache key for OpenAI-compatible endpoints. + /// Prompt cache key for OpenAI-compatible endpoints that accept it. /// When set, injected into converted requests to improve cache hit rate. - /// If not set, provider ID is used automatically during format conversion. + /// If not set, provider ID is used automatically during openai_chat/openai_responses conversion. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, /// 累加模式应用中,该 provider 是否已写入 live config。 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 13516c383..97f0f020a 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1544,7 +1544,7 @@ fn rewrite_claude_transform_endpoint( let target_path = if is_copilot && api_format == "openai_responses" { "/v1/responses" - } else if is_copilot { + } else if is_copilot || api_format == "gemini_chat" { "/chat/completions" } else if api_format == "openai_responses" { "/v1/responses" @@ -1694,6 +1694,18 @@ mod tests { assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); } + #[test] + fn rewrite_claude_transform_endpoint_uses_gemini_chat_path() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&foo=bar", + "gemini_chat", + false, + ); + + assert_eq!(endpoint, "/chat/completions?foo=bar"); + assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); + } + #[test] fn rewrite_claude_transform_endpoint_strips_beta_for_responses() { let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 2cd7c0172..6c159873a 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -5,6 +5,7 @@ //! ## API 格式 //! - **anthropic** (默认): Anthropic Messages API 格式,直接透传 //! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换 +//! - **gemini_chat**: Gemini Chat 兼容格式,走 `/chat/completions`,且不注入 `prompt_cache_key` //! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换 //! //! ## 认证模式 @@ -27,6 +28,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { if let Some(api_format) = meta.api_format.as_deref() { return match api_format { "openai_chat" => "openai_chat", + "gemini_chat" => "gemini_chat", "openai_responses" => "openai_responses", _ => "anthropic", }; @@ -41,6 +43,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { { return match api_format { "openai_chat" => "openai_chat", + "gemini_chat" => "gemini_chat", "openai_responses" => "openai_responses", _ => "anthropic", }; @@ -66,7 +69,10 @@ 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") + matches!( + api_format, + "openai_chat" | "gemini_chat" | "openai_responses" + ) } pub fn transform_claude_request_for_api_format( @@ -85,6 +91,7 @@ pub fn transform_claude_request_for_api_format( super::transform_responses::anthropic_to_responses(body, Some(cache_key)) } "openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)), + "gemini_chat" => super::transform::anthropic_to_openai(body, None), _ => Ok(body), } } @@ -155,6 +162,7 @@ impl ClaudeAdapter { /// 从 provider.meta.api_format 读取格式设置: /// - "anthropic" (默认): Anthropic Messages API 格式,直接透传 /// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + /// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key /// - "openai_responses": OpenAI Responses API 格式,需要格式转换 fn get_api_format(&self, provider: &Provider) -> &'static str { get_claude_api_format(provider) @@ -415,10 +423,11 @@ impl ProviderAdapter for ClaudeAdapter { // 根据 api_format 配置决定是否需要格式转换 // - "anthropic" (默认): 直接透传,无需转换 // - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换 + // - "gemini_chat": 需要 Anthropic ↔ Gemini Chat 兼容格式转换(不注入 prompt_cache_key) // - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换 matches!( self.get_api_format(provider), - "openai_chat" | "openai_responses" + "openai_chat" | "gemini_chat" | "openai_responses" ) } @@ -726,6 +735,20 @@ mod tests { ); assert!(adapter.needs_transform(&openai_chat_provider)); + // Gemini Chat format in meta: needs transform + let gemini_chat_provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai" + } + }), + ProviderMeta { + api_format: Some("gemini_chat".to_string()), + ..Default::default() + }, + ); + assert!(adapter.needs_transform(&gemini_chat_provider)); + // OpenAI Responses format in meta: needs transform let openai_responses_provider = create_provider_with_meta( json!({ @@ -854,4 +877,31 @@ mod tests { assert!(transformed.get("input").is_some()); assert!(transformed.get("max_output_tokens").is_some()); } + + #[test] + fn test_transform_claude_request_for_api_format_gemini_chat_omits_prompt_cache_key() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai" + } + }), + ProviderMeta { + prompt_cache_key: Some("custom-cache-key".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gemini-2.5-flash", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "gemini_chat").unwrap(); + + assert_eq!(transformed["model"], "gemini-2.5-flash"); + assert!(transformed.get("messages").is_some()); + assert!(transformed.get("prompt_cache_key").is_none()); + } } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 79f1021ed..a006e4cc1 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -309,6 +309,7 @@ impl StreamCheckService { /// 根据供应商的 api_format 选择请求格式: /// - "anthropic" (默认): Anthropic Messages API (/v1/messages) /// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions) + /// - "gemini_chat": Gemini Chat 兼容 API (/chat/completions, 不注入 prompt_cache_key) #[allow(clippy::too_many_arguments)] async fn check_claude_stream( client: &Client, @@ -344,6 +345,7 @@ impl StreamCheckService { .and_then(|meta| meta.is_full_url) .unwrap_or(false); let is_openai_chat = effective_api_format == "openai_chat"; + let is_gemini_chat = effective_api_format == "gemini_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); @@ -360,6 +362,9 @@ impl StreamCheckService { let body = if is_openai_responses { anthropic_to_responses(anthropic_body, Some(&provider.id)) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? + } else if is_gemini_chat { + anthropic_to_openai(anthropic_body, None) + .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? } else if is_openai_chat { anthropic_to_openai(anthropic_body, Some(&provider.id)) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? @@ -395,7 +400,7 @@ impl StreamCheckService { .header("x-vscode-user-agent-library-version", "electron-fetch") .header("x-request-id", &request_id) .header("x-agent-task-id", &request_id); - } else if is_openai_chat || is_openai_responses { + } else if is_openai_chat || is_gemini_chat || is_openai_responses { // OpenAI-compatible targets: Bearer auth + SSE headers only request_builder = request_builder .header("authorization", format!("Bearer {}", auth.api_key)) @@ -761,7 +766,7 @@ impl StreamCheckService { if is_github_copilot && api_format == "openai_responses" { format!("{base}/v1/responses") - } else if is_github_copilot { + } else if is_github_copilot || api_format == "gemini_chat" { format!("{base}/chat/completions") } else if api_format == "openai_responses" { if base.ends_with("/v1") { @@ -955,6 +960,21 @@ mod tests { assert_eq!(url, "https://example.com/v1/chat/completions"); } + #[test] + fn test_resolve_claude_stream_url_for_gemini_chat() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://generativelanguage.googleapis.com/v1beta/openai", + AuthStrategy::Bearer, + "gemini_chat", + false, + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + ); + } + #[test] fn test_resolve_claude_stream_url_for_openai_responses() { let url = StreamCheckService::resolve_claude_stream_url( diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 033a0874c..b464202a2 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -416,9 +416,11 @@ export function ClaudeFormFields({ hint={ apiFormat === "openai_responses" ? t("providerForm.apiHintResponses") - : apiFormat === "openai_chat" - ? t("providerForm.apiHintOAI") - : t("providerForm.apiHint") + : apiFormat === "gemini_chat" + ? t("providerForm.apiHintGeminiChat") + : apiFormat === "openai_chat" + ? t("providerForm.apiHintOAI") + : t("providerForm.apiHint") } onManageClick={() => onEndpointModalToggle(true)} showFullUrlToggle={true} @@ -488,6 +490,11 @@ export function ClaudeFormFields({ defaultValue: "OpenAI Chat Completions (需转换)", })} + + {t("providerForm.apiFormatGeminiChat", { + defaultValue: "Gemini Chat Compatible (需开启代理)", + })} + {t("providerForm.apiFormatOpenAIResponses", { defaultValue: "OpenAI Responses API (需转换)", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 2ed5dcd88..ed5783211 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -48,8 +48,9 @@ export interface ProviderPreset { // Claude API 格式(仅 Claude 供应商使用) // - "anthropic" (默认): Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + // - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key // - "openai_responses": OpenAI Responses API 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; + apiFormat?: "anthropic" | "openai_chat" | "gemini_chat" | "openai_responses"; // 供应商类型标识(用于特殊供应商检测) // - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证) diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 346de6e5b..802e47b31 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -158,6 +158,13 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", { defaultValue: "使用 OpenAI Chat 接口格式", }); + } else if ( + provider.meta?.apiFormat === "gemini_chat" && + activeApp === "claude" + ) { + proxyRequiredReason = t("notifications.proxyReasonGeminiChat", { + defaultValue: "使用 Gemini Chat 兼容接口格式", + }); } else if ( provider.meta?.apiFormat === "openai_responses" && activeApp === "claude" @@ -207,6 +214,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { provider.category !== "official" && (isCopilotProvider || provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "gemini_chat" || provider.meta?.apiFormat === "openai_responses") ) { // OpenAI format provider: show proxy hint (skip if warning already shown) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 09f421fce..7ac8b5ccb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -177,6 +177,7 @@ "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", + "proxyReasonGeminiChat": "uses Gemini Chat compatible 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", @@ -746,6 +747,7 @@ "modelHint": "💡 Leave blank to use provider's default model", "apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash", "apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash", + "apiHintGeminiChat": "💡 Fill in a Gemini Chat compatible service endpoint. For Google AI Studio, use the /v1beta/openai root and avoid a trailing slash", "codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format", "fillSupplierName": "Please fill in provider name", "fillConfigContent": "Please fill in configuration content", @@ -770,6 +772,7 @@ "fullUrlHint": "💡 Enter the full request URL. This mode requires the proxy to be enabled, and the proxy will use the URL as-is without appending a path", "apiFormatAnthropic": "Anthropic Messages (Native)", "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", + "apiFormatGeminiChat": "Gemini Chat Compatible (Requires proxy)", "apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)", "authField": "Auth Field", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 145355ef3..35fe600d4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -177,6 +177,7 @@ "proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください", "proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており", "proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており", + "proxyReasonGeminiChat": "Gemini Chat 互換 API フォーマットを使用しており", "proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており", "proxyReasonFullUrl": "完全 URL 接続モードが有効になっており", "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です", @@ -746,6 +747,7 @@ "modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します", "apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", "apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", + "apiHintGeminiChat": "💡 Gemini Chat 互換サービスのエンドポイントを入力してください。Google AI Studio の場合は /v1beta/openai ルートを使い、末尾にスラッシュを付けないでください", "codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください", "fillSupplierName": "プロバイダー名を入力してください", "fillConfigContent": "設定内容を入力してください", @@ -770,6 +772,7 @@ "fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはプロキシを有効にして使用する必要があり、プロキシはこの URL をそのまま使用し、パスを追加しません", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", + "apiFormatGeminiChat": "Gemini Chat Compatible(プロキシが必要)", "apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)", "authField": "認証フィールド", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 75fc9232a..f40d76f7c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -177,6 +177,7 @@ "proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理", "proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商", "proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式", + "proxyReasonGeminiChat": "使用 Gemini Chat 兼容接口格式", "proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式", "proxyReasonFullUrl": "开启了完整 URL 连接模式", "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", @@ -746,6 +747,7 @@ "modelHint": "💡 留空将使用供应商的默认模型", "apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾", "apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾", + "apiHintGeminiChat": "💡 填写兼容 Gemini Chat 的服务端点地址,例如 Google AI Studio 可填写到 /v1beta/openai,且不要以斜杠结尾", "codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址", "fillSupplierName": "请填写供应商名称", "fillConfigContent": "请填写配置内容", @@ -770,6 +772,7 @@ "fullUrlHint": "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径", "apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", + "apiFormatGeminiChat": "Gemini Chat Compatible (需开启代理)", "apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)", "authField": "认证字段", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)", diff --git a/src/types.ts b/src/types.ts index 868a05cc6..75e0299ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -158,15 +158,16 @@ export interface ProviderMeta { // Claude API 格式(仅 Claude 供应商使用) // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + // - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key // - "openai_responses": OpenAI Responses API 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; + apiFormat?: "anthropic" | "openai_chat" | "gemini_chat" | "openai_responses"; // 通用认证绑定 authBinding?: AuthBinding; // Claude 认证字段名 apiKeyField?: ClaudeApiKeyField; // 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径) isFullUrl?: boolean; - // Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate) + // Prompt cache key for compatible endpoints that accept it (not used by gemini_chat) promptCacheKey?: string; // 供应商类型(用于识别 Copilot 等特殊供应商) providerType?: string; @@ -180,8 +181,13 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy"; // Claude API 格式类型 // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 +// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key // - "openai_responses": OpenAI Responses API 格式,需要格式转换 -export type ClaudeApiFormat = "anthropic" | "openai_chat" | "openai_responses"; +export type ClaudeApiFormat = + | "anthropic" + | "openai_chat" + | "gemini_chat" + | "openai_responses"; // Claude 认证字段类型 export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";