From 81b975c47c2f1534537d7bd8dbb4798990564dda Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 27 Jan 2026 08:38:37 +0800 Subject: [PATCH] feat(claude): add API format selector for third-party providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the OpenRouter-specific compatibility toggle with a generic API format selector that allows all Claude providers to choose between: - Anthropic Messages (native): Direct passthrough, no conversion - OpenAI Chat Completions: Enables Anthropic ↔ OpenAI format conversion Changes: - Add ClaudeApiFormat type ("anthropic" | "openai_chat") to types.ts - Replace openRouterCompatToggle with apiFormat dropdown in ClaudeFormFields - Update ProviderForm to manage apiFormat state via settingsConfig.api_format - Refactor claude.rs: add get_api_format() method, update needs_transform() - Maintain backward compatibility with legacy openrouter_compat_mode field - Update i18n translations (zh, en, ja) --- src-tauri/src/proxy/providers/claude.rs | 99 ++++++++++++++----- .../providers/forms/ClaudeFormFields.tsx | 69 +++++++------ .../providers/forms/ProviderForm.tsx | 50 ++++------ src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh.json | 6 +- src/types.ts | 5 + 7 files changed, 148 insertions(+), 93 deletions(-) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 04d4ec6ff..26a061587 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -1,11 +1,15 @@ //! Claude (Anthropic) Provider Adapter //! -//! 支持透传模式和 OpenRouter 兼容模式 +//! 支持透传模式和 OpenAI Chat Completions 格式转换模式 +//! +//! ## API 格式 +//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传 +//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换 //! //! ## 认证模式 //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) -//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用) +//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传 use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use crate::provider::Provider; @@ -48,23 +52,44 @@ impl ClaudeAdapter { false } - /// 检测 OpenRouter 是否启用兼容模式 - fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool { - if !self.is_openrouter(provider) { - return false; + /// 获取 API 格式 + /// + /// 从 settings_config.api_format 读取格式设置: + /// - "anthropic" (默认): Anthropic Messages API 格式,直接透传 + /// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + /// + /// 为了向后兼容,如果存在旧的 openrouter_compat_mode=true,也会启用 openai_chat 格式 + fn get_api_format(&self, provider: &Provider) -> &'static str { + // 1. 首先检查新的 api_format 字段 + if let Some(api_format) = provider + .settings_config + .get("api_format") + .and_then(|v| v.as_str()) + { + return match api_format { + "openai_chat" => "openai_chat", + _ => "anthropic", + }; } + // 2. 向后兼容:检查旧的 openrouter_compat_mode 字段 let raw = provider.settings_config.get("openrouter_compat_mode"); - match raw { + let is_compat_enabled = 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" } - // OpenRouter now supports Claude Code compatible API, default to passthrough _ => false, + }; + + if is_compat_enabled { + return "openai_chat"; } + + // 3. 默认使用 Anthropic 原生格式 + "anthropic" } /// 检测是否为仅 Bearer 认证模式 @@ -253,13 +278,11 @@ 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 { + // 根据 api_format 配置决定是否需要格式转换 + // - "anthropic" (默认): 直接透传,无需转换 + // - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换 + self.get_api_format(provider) == "openai_chat" } fn transform_request( @@ -459,6 +482,7 @@ mod tests { fn test_needs_transform() { let adapter = ClaudeAdapter::new(); + // Default: no transform (anthropic format) let anthropic_provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" @@ -466,29 +490,50 @@ mod tests { })); assert!(!adapter.needs_transform(&anthropic_provider)); - // OpenRouter provider without explicit setting now defaults to passthrough (no transform) - let openrouter_provider = create_provider(json!({ + // Explicit anthropic format: no transform + let explicit_anthropic = create_provider(json!({ "env": { - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" - } + "ANTHROPIC_BASE_URL": "https://api.example.com" + }, + "api_format": "anthropic" })); - assert!(!adapter.needs_transform(&openrouter_provider)); + assert!(!adapter.needs_transform(&explicit_anthropic)); - // OpenRouter provider with explicit compat mode enabled should transform - let openrouter_enabled = create_provider(json!({ + // OpenAI Chat format: needs transform + let openai_chat_provider = create_provider(json!({ "env": { - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" + "ANTHROPIC_BASE_URL": "https://api.example.com" + }, + "api_format": "openai_chat" + })); + assert!(adapter.needs_transform(&openai_chat_provider)); + + // Backward compatibility: openrouter_compat_mode=true should enable transform + let legacy_compat_enabled = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": true })); - assert!(adapter.needs_transform(&openrouter_enabled)); + assert!(adapter.needs_transform(&legacy_compat_enabled)); - let openrouter_disabled = create_provider(json!({ + // Backward compatibility: openrouter_compat_mode=false should not enable transform + let legacy_compat_disabled = create_provider(json!({ "env": { - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" + "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": false })); - assert!(!adapter.needs_transform(&openrouter_disabled)); + assert!(!adapter.needs_transform(&legacy_compat_disabled)); + + // api_format takes precedence over openrouter_compat_mode + let format_precedence = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + }, + "api_format": "anthropic", + "openrouter_compat_mode": true + })); + assert!(!adapter.needs_transform(&format_precedence)); } } diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index d16dd9669..75181972f 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -1,10 +1,16 @@ import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; -import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField } from "./shared"; -import type { ProviderCategory } from "@/types"; +import type { ProviderCategory, ClaudeApiFormat } from "@/types"; import type { TemplateValueConfig } from "@/config/claudeProviderPresets"; interface EndpointCandidate { @@ -59,10 +65,9 @@ interface ClaudeFormFieldsProps { // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; - // OpenRouter Compat - showOpenRouterCompatToggle: boolean; - openRouterCompatEnabled: boolean; - onOpenRouterCompatChange: (enabled: boolean) => void; + // API Format (for third-party providers that use OpenAI Chat Completions format) + apiFormat: ClaudeApiFormat; + onApiFormatChange: (format: ClaudeApiFormat) => void; } export function ClaudeFormFields({ @@ -95,9 +100,8 @@ export function ClaudeFormFields({ defaultOpusModel, onModelChange, speedTestEndpoints, - showOpenRouterCompatToggle, - openRouterCompatEnabled, - onOpenRouterCompatChange, + apiFormat, + onApiFormatChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -180,25 +184,34 @@ export function ClaudeFormFields({ /> )} - {showOpenRouterCompatToggle && ( -
-
- - {t("providerForm.openrouterCompatMode", { - defaultValue: "OpenRouter 兼容模式", - })} - -

- {t("providerForm.openrouterCompatModeHint", { - defaultValue: - "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", - })} -

-
- + {/* API 格式选择(仅非官方供应商显示) */} + {shouldShowModelSelector && ( +
+ + {t("providerForm.apiFormat", { defaultValue: "API 格式" })} + + +

+ {t("providerForm.apiFormatHint", { + defaultValue: "选择供应商 API 的输入格式", + })} +

)} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 976690f4d..a77a8a48e 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -13,6 +13,7 @@ import type { ProviderMeta, ProviderTestConfig, ProviderProxyConfig, + ClaudeApiFormat, } from "@/types"; import { providerPresets, @@ -284,45 +285,31 @@ export function ProviderForm({ onConfigChange: (config) => form.setValue("settingsConfig", config), }); - const isOpenRouterProvider = useMemo(() => { - if (appId !== "claude") return false; - const normalized = baseUrl.trim().toLowerCase(); - if (normalized.includes("openrouter.ai")) { - return true; - } + // Claude API Format state + // Read initial value from settingsConfig.api_format, default to "anthropic" + const apiFormat = useMemo(() => { + if (appId !== "claude") return "anthropic"; try { const config = JSON.parse(settingsConfigValue || "{}"); - const envUrl = config?.env?.ANTHROPIC_BASE_URL; - return typeof envUrl === "string" && envUrl.includes("openrouter.ai"); - } catch { - return false; - } - }, [appId, baseUrl, settingsConfigValue]); - - const openRouterCompatEnabled = useMemo(() => { - if (!isOpenRouterProvider) return false; - try { - const config = JSON.parse(settingsConfigValue || "{}"); - const raw = config?.openrouter_compat_mode; - if (typeof raw === "boolean") return raw; - if (typeof raw === "number") return raw !== 0; - if (typeof raw === "string") { - const normalized = raw.trim().toLowerCase(); - return normalized === "true" || normalized === "1"; - } + const format = config?.api_format; + if (format === "openai_chat") return "openai_chat"; + // Backward compatibility: if old openrouter_compat_mode is true, treat as openai_chat + if (config?.openrouter_compat_mode === true) return "openai_chat"; } catch { // ignore } - return false; // OpenRouter now supports Claude Code compatible API, no need for transform - }, [isOpenRouterProvider, settingsConfigValue]); + return "anthropic"; + }, [appId, settingsConfigValue]); - const handleOpenRouterCompatChange = useCallback( - (enabled: boolean) => { + const handleApiFormatChange = useCallback( + (format: ClaudeApiFormat) => { try { const currentConfig = JSON.parse( form.getValues("settingsConfig") || "{}", ); - currentConfig.openrouter_compat_mode = enabled; + currentConfig.api_format = format; + // Clean up legacy field + delete currentConfig.openrouter_compat_mode; form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2)); } catch { // ignore @@ -1294,9 +1281,8 @@ export function ProviderForm({ defaultOpusModel={defaultOpusModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} - showOpenRouterCompatToggle={false} - openRouterCompatEnabled={openRouterCompatEnabled} - onOpenRouterCompatChange={handleOpenRouterCompatChange} + apiFormat={apiFormat} + onApiFormatChange={handleApiFormatChange} /> )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d507d4464..11c67446c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -472,8 +472,10 @@ "anthropicModel": "Main Model", "anthropicSmallFastModel": "Fast Model", "anthropicReasoningModel": "Reasoning Model (Thinking)", - "openrouterCompatMode": "OpenRouter Compatibility Mode", - "openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.", + "apiFormat": "API Format", + "apiFormatHint": "Select the input format for the provider's API", + "apiFormatAnthropic": "Anthropic Messages (Native)", + "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires conversion)", "anthropicDefaultHaikuModel": "Default Haiku Model", "anthropicDefaultSonnetModel": "Default Sonnet Model", "anthropicDefaultOpusModel": "Default Opus Model", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index eb74df7fd..5d74ce84b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -472,8 +472,10 @@ "anthropicModel": "メインモデル", "anthropicSmallFastModel": "高速モデル", "anthropicReasoningModel": "推論モデル(Thinking)", - "openrouterCompatMode": "OpenRouter 互換モード", - "openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。", + "apiFormat": "API フォーマット", + "apiFormatHint": "プロバイダー API の入力フォーマットを選択", + "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", + "apiFormatOpenAIChat": "OpenAI Chat Completions(変換が必要)", "anthropicDefaultHaikuModel": "既定 Haiku モデル", "anthropicDefaultSonnetModel": "既定 Sonnet モデル", "anthropicDefaultOpusModel": "既定 Opus モデル", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7f92092c5..f5cbba3d3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -472,8 +472,10 @@ "anthropicModel": "主模型", "anthropicSmallFastModel": "快速模型", "anthropicReasoningModel": "推理模型 (Thinking)", - "openrouterCompatMode": "OpenRouter 兼容模式", - "openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", + "apiFormat": "API 格式", + "apiFormatHint": "选择供应商 API 的输入格式", + "apiFormatAnthropic": "Anthropic Messages (原生)", + "apiFormatOpenAIChat": "OpenAI Chat Completions (需转换)", "anthropicDefaultHaikuModel": "Haiku 默认模型", "anthropicDefaultSonnetModel": "Sonnet 默认模型", "anthropicDefaultOpusModel": "Opus 默认模型", diff --git a/src/types.ts b/src/types.ts index aea63d4ce..25ef4b646 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,6 +144,11 @@ export interface ProviderMeta { // Skill 同步方式 export type SkillSyncMethod = "auto" | "symlink" | "copy"; +// Claude API 格式类型 +// - "anthropic": 原生 Anthropic Messages API 格式,直接透传 +// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 +export type ClaudeApiFormat = "anthropic" | "openai_chat"; + // 主页面显示的应用配置 export interface VisibleApps { claude: boolean;