From e44423c307b9b8e0cc88042f2e6fbffe403b5aa2 Mon Sep 17 00:00:00 2001 From: "n1n.ai" <1119883939@qq.com> Date: Fri, 30 Jan 2026 12:45:01 +0800 Subject: [PATCH 01/18] feat: add n1n.ai provider preset (#667) Co-authored-by: n1n --- .../src/config/universalProviderPresets.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 cc-switch-main/src/config/universalProviderPresets.ts diff --git a/cc-switch-main/src/config/universalProviderPresets.ts b/cc-switch-main/src/config/universalProviderPresets.ts new file mode 100644 index 000000000..e2f9cd04b --- /dev/null +++ b/cc-switch-main/src/config/universalProviderPresets.ts @@ -0,0 +1,162 @@ +/** + * 统一供应商(Universal Provider)预设配置 + * + * 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。 + * 适用于 NewAPI 等支持多种协议的 API 网关。 + */ + +import type { + UniversalProvider, + UniversalProviderApps, + UniversalProviderModels, +} from "@/types"; + +/** + * 统一供应商预设接口 + */ +export interface UniversalProviderPreset { + /** 预设名称 */ + name: string; + /** 供应商类型标识 */ + providerType: string; + /** 默认启用的应用 */ + defaultApps: UniversalProviderApps; + /** 默认模型配置 */ + defaultModels: UniversalProviderModels; + /** 网站链接 */ + websiteUrl?: string; + /** 图标名称 */ + icon?: string; + /** 图标颜色 */ + iconColor?: string; + /** 描述 */ + description?: string; + /** 是否为自定义模板(允许用户完全自定义) */ + isCustomTemplate?: boolean; +} + +/** + * NewAPI 默认模型配置 + */ +const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = { + claude: { + model: "claude-sonnet-4-20250514", + haikuModel: "claude-haiku-4-20250514", + sonnetModel: "claude-sonnet-4-20250514", + opusModel: "claude-sonnet-4-20250514", + }, + codex: { + model: "gpt-4o", + reasoningEffort: "high", + }, + gemini: { + model: "gemini-2.5-pro", + }, +}; + +const N1N_DEFAULT_MODELS: UniversalProviderModels = { + claude: { + model: "claude-3-5-sonnet-20240620", + haikuModel: "claude-3-haiku-20240307", + sonnetModel: "claude-3-5-sonnet-20240620", + opusModel: "claude-3-opus-20240229", + }, + codex: { + model: "gpt-4o", + reasoningEffort: "high", + }, + gemini: { + model: "gemini-1.5-pro-latest", + }, +}; + +/** + * 统一供应商预设列表 + */ +export const universalProviderPresets: UniversalProviderPreset[] = [ + { + name: "n1n.ai", + providerType: "n1n", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: N1N_DEFAULT_MODELS, + websiteUrl: "https://n1n.ai", + icon: "openai", + iconColor: "#000000", + description: + "n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台", + }, + { + name: "NewAPI", + providerType: "newapi", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: NEWAPI_DEFAULT_MODELS, + websiteUrl: "https://www.newapi.pro", + icon: "newapi", + iconColor: "#00A67E", + description: + "NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议", + }, + { + name: "自定义网关", + providerType: "custom_gateway", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: NEWAPI_DEFAULT_MODELS, + icon: "openai", + iconColor: "#6366F1", + description: "自定义配置的 API 网关", + isCustomTemplate: true, + }, +]; + +/** + * 根据预设创建统一供应商 + */ +export function createUniversalProviderFromPreset( + preset: UniversalProviderPreset, + id: string, + baseUrl: string, + apiKey: string, + customName?: string, +): UniversalProvider { + return { + id, + name: customName || preset.name, + providerType: preset.providerType, + apps: { ...preset.defaultApps }, + baseUrl, + apiKey, + models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy + websiteUrl: preset.websiteUrl, + icon: preset.icon, + iconColor: preset.iconColor, + createdAt: Date.now(), + }; +} + +/** + * 获取预设的显示名称(用于 UI) + */ +export function getPresetDisplayName(preset: UniversalProviderPreset): string { + return preset.name; +} + +/** + * 根据类型查找预设 + */ +export function findPresetByType( + providerType: string, +): UniversalProviderPreset | undefined { + return universalProviderPresets.find((p) => p.providerType === providerType); +} From 81b975c47c2f1534537d7bd8dbb4798990564dda Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 27 Jan 2026 08:38:37 +0800 Subject: [PATCH 02/18] 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; From 0c25687e0921b2e470208d79d4904b63adbb0a29 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 29 Jan 2026 10:52:13 +0800 Subject: [PATCH 03/18] fix(claude): improve backward compatibility for openrouter_compat_mode Extend backward compatibility support for legacy openrouter_compat_mode field: - Support number type (1 = enabled, 0 = disabled) - Support string type ("true"/"1" = enabled) - Add corresponding test cases for number and string types --- src-tauri/src/proxy/providers/claude.rs | 18 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 26a061587..3d6fbb1f9 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -517,6 +517,24 @@ mod tests { })); assert!(adapter.needs_transform(&legacy_compat_enabled)); + // Backward compatibility: openrouter_compat_mode=1 should enable transform + let legacy_compat_enabled_num = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + }, + "openrouter_compat_mode": 1 + })); + assert!(adapter.needs_transform(&legacy_compat_enabled_num)); + + // Backward compatibility: openrouter_compat_mode="true" should enable transform + let legacy_compat_enabled_str = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + }, + "openrouter_compat_mode": "true" + })); + assert!(adapter.needs_transform(&legacy_compat_enabled_str)); + // Backward compatibility: openrouter_compat_mode=false should not enable transform let legacy_compat_disabled = create_provider(json!({ "env": { diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index a77a8a48e..5db010150 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -292,9 +292,19 @@ export function ProviderForm({ try { const config = JSON.parse(settingsConfigValue || "{}"); 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"; + if (typeof format === "string") { + return format === "openai_chat" ? "openai_chat" : "anthropic"; + } + + // Backward compatibility: old openrouter_compat_mode (bool/number/string) + const raw = config?.openrouter_compat_mode; + if (typeof raw === "boolean") return raw ? "openai_chat" : "anthropic"; + if (typeof raw === "number") return raw !== 0 ? "openai_chat" : "anthropic"; + if (typeof raw === "string") { + const normalized = raw.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return "openai_chat"; + return "anthropic"; + } } catch { // ignore } From 964767ebaf82eb8c59ccd00a6e6ab659a1947ba9 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 29 Jan 2026 10:58:28 +0800 Subject: [PATCH 04/18] fix(i18n): update apiFormatOpenAIChat label to mention proxy requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change label from "Requires conversion" to "Requires proxy" for clarity: - zh: "需转换" → "需开启代理" - en: "Requires conversion" → "Requires proxy" - ja: "変換が必要" → "プロキシが必要" --- src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 11c67446c..b0d10c987 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -475,7 +475,7 @@ "apiFormat": "API Format", "apiFormatHint": "Select the input format for the provider's API", "apiFormatAnthropic": "Anthropic Messages (Native)", - "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires conversion)", + "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", "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 5d74ce84b..5f028a8cc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -475,7 +475,7 @@ "apiFormat": "API フォーマット", "apiFormatHint": "プロバイダー API の入力フォーマットを選択", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", - "apiFormatOpenAIChat": "OpenAI Chat Completions(変換が必要)", + "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 f5cbba3d3..5126f5824 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -475,7 +475,7 @@ "apiFormat": "API 格式", "apiFormatHint": "选择供应商 API 的输入格式", "apiFormatAnthropic": "Anthropic Messages (原生)", - "apiFormatOpenAIChat": "OpenAI Chat Completions (需转换)", + "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", "anthropicDefaultHaikuModel": "Haiku 默认模型", "anthropicDefaultSonnetModel": "Sonnet 默认模型", "anthropicDefaultOpusModel": "Opus 默认模型", From 70a18c114148dacf2351e55a276d5d70864d2a2e Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 29 Jan 2026 15:15:27 +0800 Subject: [PATCH 05/18] refactor(claude): migrate api_format from settings_config to meta Move api_format storage from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json when switching providers. - Add api_format field to ProviderMeta (Rust + TypeScript) - Update ProviderForm to read/write apiFormat from meta - Maintain backward compatibility for legacy settings_config.api_format and openrouter_compat_mode fields (read-only fallback) - Strip api_format from settings_config before writing to live config --- src-tauri/src/provider.rs | 96 +++++------ src-tauri/src/proxy/providers/claude.rs | 153 ++++++++++++------ src-tauri/src/services/config.rs | 5 +- src-tauri/src/services/provider/live.rs | 15 +- src-tauri/src/services/provider/mod.rs | 1 + src-tauri/src/services/proxy.rs | 3 +- .../providers/forms/ProviderForm.tsx | 57 ++----- src/types.ts | 4 + 8 files changed, 190 insertions(+), 144 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 2bc629828..cbeee21d0 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -230,6 +230,11 @@ pub struct ProviderMeta { /// 供应商单独的代理配置 #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")] pub proxy_config: Option, + /// Claude API 格式(仅 Claude 供应商使用) + /// - "anthropic": 原生 Anthropic Messages API,直接透传 + /// - "openai_chat": OpenAI Chat Completions 格式,需要转换 + #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] + pub api_format: Option, } impl ProviderManager { @@ -528,54 +533,6 @@ requires_openai_auth = true"# } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn universal_codex_provider_origin_base_url_adds_v1() { - let mut p = UniversalProvider::new( - "id".to_string(), - "Test".to_string(), - "custom".to_string(), - "https://api.openai.com".to_string(), - "sk-test".to_string(), - ); - p.apps.codex = true; - - let provider = p.to_codex_provider().expect("should build codex provider"); - let toml = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()) - .expect("config should be a toml string"); - - assert!(toml.contains("base_url = \"https://api.openai.com/v1\"")); - } - - #[test] - fn universal_codex_provider_custom_prefix_does_not_force_v1() { - let mut p = UniversalProvider::new( - "id".to_string(), - "Test".to_string(), - "custom".to_string(), - "https://example.com/openai".to_string(), - "sk-test".to_string(), - ); - p.apps.codex = true; - - let provider = p.to_codex_provider().expect("should build codex provider"); - let toml = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()) - .expect("config should be a toml string"); - - assert!(toml.contains("base_url = \"https://example.com/openai\"")); - assert!(!toml.contains("https://example.com/openai/v1")); - } -} - // ============================================================================ // OpenCode 供应商配置结构 // ============================================================================ @@ -935,4 +892,47 @@ mod tests { assert!(config.options.headers.is_none()); assert!(config.options.extra.is_empty()); } + + #[test] + fn universal_codex_provider_origin_base_url_adds_v1() { + let mut p = UniversalProvider::new( + "id".to_string(), + "Test".to_string(), + "custom".to_string(), + "https://api.openai.com".to_string(), + "sk-test".to_string(), + ); + p.apps.codex = true; + + let provider = p.to_codex_provider().expect("should build codex provider"); + let toml = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config should be a toml string"); + + assert!(toml.contains("base_url = \"https://api.openai.com/v1\"")); + } + + #[test] + fn universal_codex_provider_custom_prefix_does_not_force_v1() { + let mut p = UniversalProvider::new( + "id".to_string(), + "Test".to_string(), + "custom".to_string(), + "https://example.com/openai".to_string(), + "sk-test".to_string(), + ); + p.apps.codex = true; + + let provider = p.to_codex_provider().expect("should build codex provider"); + let toml = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config should be a toml string"); + + assert!(toml.contains("base_url = \"https://example.com/openai\"")); + assert!(!toml.contains("https://example.com/openai/v1")); + } } diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 3d6fbb1f9..195b2be02 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -54,28 +54,38 @@ impl ClaudeAdapter { /// 获取 API 格式 /// - /// 从 settings_config.api_format 读取格式设置: + /// 从 provider.meta.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 字段 + // 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config) + if let Some(meta) = provider.meta.as_ref() { + if let Some(api_format) = meta.api_format.as_deref() { + return if api_format == "openai_chat" { + "openai_chat" + } else { + "anthropic" + }; + } + } + + // 2) Backward compatibility: legacy settings_config.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", + return if api_format == "openai_chat" { + "openai_chat" + } else { + "anthropic" }; } - // 2. 向后兼容:检查旧的 openrouter_compat_mode 字段 + // 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string) let raw = provider.settings_config.get("openrouter_compat_mode"); - let is_compat_enabled = match raw { - Some(serde_json::Value::Bool(enabled)) => *enabled, + let enabled = match raw { + Some(serde_json::Value::Bool(v)) => *v, 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(); @@ -84,12 +94,11 @@ impl ClaudeAdapter { _ => false, }; - if is_compat_enabled { - return "openai_chat"; + if enabled { + "openai_chat" + } else { + "anthropic" } - - // 3. 默认使用 Anthropic 原生格式 - "anthropic" } /// 检测是否为仅 Bearer 认证模式 @@ -301,6 +310,7 @@ impl ProviderAdapter for ClaudeAdapter { #[cfg(test)] mod tests { use super::*; + use crate::provider::ProviderMeta; use serde_json::json; fn create_provider(config: serde_json::Value) -> Provider { @@ -320,6 +330,23 @@ mod tests { } } + fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider { + Provider { + id: "test".to_string(), + name: "Test Claude".to_string(), + settings_config: config, + website_url: None, + category: Some("claude".to_string()), + created_at: None, + sort_index: None, + notes: None, + meta: Some(meta), + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + #[test] fn test_extract_base_url_from_env() { let adapter = ClaudeAdapter::new(); @@ -482,7 +509,7 @@ mod tests { fn test_needs_transform() { let adapter = ClaudeAdapter::new(); - // Default: no transform (anthropic format) + // Default: no transform (anthropic format) - no meta let anthropic_provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" @@ -490,68 +517,96 @@ mod tests { })); assert!(!adapter.needs_transform(&anthropic_provider)); - // Explicit anthropic format: no transform - let explicit_anthropic = create_provider(json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.example.com" + // Explicit anthropic format in meta: no transform + let explicit_anthropic = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + } + }), + ProviderMeta { + api_format: Some("anthropic".to_string()), + ..Default::default() }, - "api_format": "anthropic" - })); + ); assert!(!adapter.needs_transform(&explicit_anthropic)); - // OpenAI Chat format: needs transform - let openai_chat_provider = create_provider(json!({ + // Legacy settings_config.api_format: openai_chat should enable transform + let legacy_settings_api_format = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "api_format": "openai_chat" })); - assert!(adapter.needs_transform(&openai_chat_provider)); + assert!(adapter.needs_transform(&legacy_settings_api_format)); - // Backward compatibility: openrouter_compat_mode=true should enable transform - let legacy_compat_enabled = create_provider(json!({ + // Legacy openrouter_compat_mode: bool/number/string should enable transform + let legacy_openrouter_bool = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": true })); - assert!(adapter.needs_transform(&legacy_compat_enabled)); + assert!(adapter.needs_transform(&legacy_openrouter_bool)); - // Backward compatibility: openrouter_compat_mode=1 should enable transform - let legacy_compat_enabled_num = create_provider(json!({ + let legacy_openrouter_num = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": 1 })); - assert!(adapter.needs_transform(&legacy_compat_enabled_num)); + assert!(adapter.needs_transform(&legacy_openrouter_num)); - // Backward compatibility: openrouter_compat_mode="true" should enable transform - let legacy_compat_enabled_str = create_provider(json!({ + let legacy_openrouter_str = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com" }, "openrouter_compat_mode": "true" })); - assert!(adapter.needs_transform(&legacy_compat_enabled_str)); + assert!(adapter.needs_transform(&legacy_openrouter_str)); - // Backward compatibility: openrouter_compat_mode=false should not enable transform - let legacy_compat_disabled = create_provider(json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.example.com" + // OpenAI Chat format in meta: needs transform + let openai_chat_provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() }, - "openrouter_compat_mode": false - })); - assert!(!adapter.needs_transform(&legacy_compat_disabled)); + ); + assert!(adapter.needs_transform(&openai_chat_provider)); - // api_format takes precedence over openrouter_compat_mode - let format_precedence = create_provider(json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.example.com" + // meta takes precedence over legacy settings_config fields + let meta_precedence_over_settings = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + }, + "api_format": "openai_chat", + "openrouter_compat_mode": true + }), + ProviderMeta { + api_format: Some("anthropic".to_string()), + ..Default::default() }, - "api_format": "anthropic", - "openrouter_compat_mode": true - })); - assert!(!adapter.needs_transform(&format_precedence)); + ); + assert!(!adapter.needs_transform(&meta_precedence_over_settings)); + + // Unknown format in meta: default to anthropic (no transform) + let unknown_format = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + } + }), + ProviderMeta { + api_format: Some("unknown".to_string()), + ..Default::default() + }, + ); + assert!(!adapter.needs_transform(&unknown_format)); } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 229eebdfb..a226e93ae 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -1,4 +1,4 @@ -use super::provider::ProviderService; +use super::provider::{sanitize_claude_settings_for_live, ProviderService}; use crate::app_config::{AppType, MultiAppConfig}; use crate::error::AppError; use crate::provider::Provider; @@ -181,7 +181,8 @@ impl ConfigService { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - write_json_file(&settings_path, &provider.settings_config)?; + let settings = sanitize_claude_settings_for_live(&provider.settings_config); + write_json_file(&settings_path, &settings)?; let live_after = read_json_file::(&settings_path)?; if let Some(manager) = config.get_manager_mut(&AppType::Claude) { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 02e55c0a7..52437e6dd 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -19,6 +19,18 @@ use super::gemini_auth::{ }; use super::normalize_claude_models_in_value; +pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { + let mut v = settings.clone(); + if let Some(obj) = v.as_object_mut() { + // Internal-only fields - never write to Claude Code settings.json + obj.remove("api_format"); + obj.remove("apiFormat"); + obj.remove("openrouter_compat_mode"); + obj.remove("openrouterCompatMode"); + } + v +} + /// Live configuration snapshot for backup/restore #[derive(Clone)] #[allow(dead_code)] @@ -97,7 +109,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re match app_type { AppType::Claude => { let path = get_claude_settings_path(); - write_json_file(&path, &provider.settings_config)?; + let settings = sanitize_claude_settings_for_live(&provider.settings_config); + write_json_file(&path, &settings)?; } AppType::Codex => { let obj = provider diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 8080d84b5..71f6cbe1b 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -26,6 +26,7 @@ pub use live::{ }; // Internal re-exports (pub(crate)) +pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::write_live_snapshot; // Internal re-exports diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index b22043ad2..0f429b030 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1654,7 +1654,8 @@ impl ProxyService { fn write_claude_live(&self, config: &Value) -> Result<(), String> { let path = get_claude_settings_path(); - write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}")) + let settings = crate::services::provider::sanitize_claude_settings_for_live(config); + write_json_file(&path, &settings).map_err(|e| format!("写入 Claude 配置失败: {e}")) } fn read_codex_live(&self) -> Result { diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 5db010150..e2258e8ff 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -245,8 +245,6 @@ export function ProviderForm({ mode: "onSubmit", }); - const settingsConfigValue = form.getValues("settingsConfig"); - // 使用 API Key hook const { apiKey, @@ -285,48 +283,16 @@ export function ProviderForm({ onConfigChange: (config) => form.setValue("settingsConfig", config), }); - // Claude API Format state - // Read initial value from settingsConfig.api_format, default to "anthropic" - const apiFormat = useMemo(() => { + // Claude API Format state - stored in meta, not settingsConfig + // Read initial value from meta.apiFormat, default to "anthropic" + const [localApiFormat, setLocalApiFormat] = useState(() => { if (appId !== "claude") return "anthropic"; - try { - const config = JSON.parse(settingsConfigValue || "{}"); - const format = config?.api_format; - if (typeof format === "string") { - return format === "openai_chat" ? "openai_chat" : "anthropic"; - } + return initialData?.meta?.apiFormat ?? "anthropic"; + }); - // Backward compatibility: old openrouter_compat_mode (bool/number/string) - const raw = config?.openrouter_compat_mode; - if (typeof raw === "boolean") return raw ? "openai_chat" : "anthropic"; - if (typeof raw === "number") return raw !== 0 ? "openai_chat" : "anthropic"; - if (typeof raw === "string") { - const normalized = raw.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return "openai_chat"; - return "anthropic"; - } - } catch { - // ignore - } - return "anthropic"; - }, [appId, settingsConfigValue]); - - const handleApiFormatChange = useCallback( - (format: ClaudeApiFormat) => { - try { - const currentConfig = JSON.parse( - form.getValues("settingsConfig") || "{}", - ); - currentConfig.api_format = format; - // Clean up legacy field - delete currentConfig.openrouter_compat_mode; - form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2)); - } catch { - // ignore - } - }, - [form], - ); + const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => { + setLocalApiFormat(format); + }, []); // 使用 Codex 配置 hook (仅 Codex 模式) const { @@ -972,6 +938,11 @@ export function ProviderForm({ pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit" ? pricingConfig.pricingModelSource : undefined, + // Claude API 格式(仅非官方 Claude 供应商使用) + apiFormat: + appId === "claude" && category !== "official" + ? localApiFormat + : undefined, }; onSubmit(payload); @@ -1291,7 +1262,7 @@ export function ProviderForm({ defaultOpusModel={defaultOpusModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} - apiFormat={apiFormat} + apiFormat={localApiFormat} onApiFormatChange={handleApiFormatChange} /> )} diff --git a/src/types.ts b/src/types.ts index 25ef4b646..2416e3730 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,6 +139,10 @@ export interface ProviderMeta { costMultiplier?: string; // 供应商计费模式来源 pricingModelSource?: string; + // Claude API 格式(仅 Claude 供应商使用) + // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 + // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + apiFormat?: "anthropic" | "openai_chat"; } // Skill 同步方式 From 065d5db843c4108c8f8e4d9d84b27b72e4be7e28 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 30 Jan 2026 12:03:37 +0800 Subject: [PATCH 06/18] fix(proxy): improve URL building and remove redundant model mapping - Add model parameter to request logs for better debugging - Fix duplicate /v1/v1 in URL when both base_url and endpoint have version - Extend ?beta=true parameter to /v1/chat/completions endpoint - Remove model mapping from transform layer (now handled by model_mapper) - Add DeepSeek max_tokens clamping (1-8192 range) --- src-tauri/src/proxy/forwarder.rs | 6 +- src-tauri/src/proxy/providers/claude.rs | 14 +- src-tauri/src/proxy/providers/transform.rs | 169 +++++++-------------- 3 files changed, 67 insertions(+), 122 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 8924d9b2f..2c741b8a6 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -666,7 +666,11 @@ impl RequestForwarder { // 输出请求信息日志 let tag = adapter.name(); - log::info!("[{tag}] >>> 请求 URL: {url}"); + let request_model = filtered_body + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or(""); + log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})"); if let Ok(body_str) = serde_json::to_string(&filtered_body) { log::debug!( "[{tag}] >>> 请求体内容 ({}字节): {}", diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 195b2be02..59d0da43f 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -252,15 +252,23 @@ impl ProviderAdapter for ClaudeAdapter { // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 - let base = format!( + let mut base = format!( "{}/{}", base_url.trim_end_matches('/'), endpoint.trim_start_matches('/') ); - // 为 /v1/messages 端点添加 ?beta=true 参数 + // 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致) + while base.contains("/v1/v1") { + base = base.replace("/v1/v1", "/v1"); + } + + // 为 Claude 相关端点添加 ?beta=true 参数 // 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数 - if endpoint.contains("/v1/messages") && !endpoint.contains("?") { + // 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致 + if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions")) + && !endpoint.contains('?') + { format!("{base}?beta=true") } else { base diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 1efbb0956..a73d77f28 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -7,73 +7,26 @@ use crate::provider::Provider; use crate::proxy::error::ProxyError; use serde_json::{json, Value}; -/// 从 Provider 配置中获取模型映射 -fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String { - let env = provider.settings_config.get("env"); - let model_lower = model.to_lowercase(); +fn extract_base_url_from_provider(provider: &Provider) -> Option<&str> { + let settings = &provider.settings_config; - // 检测 thinking 参数 - let has_thinking = body - .get("thinking") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("type")) - .and_then(|t| t.as_str()) - == Some("enabled"); - - if let Some(env) = env { - // 如果启用 thinking,优先使用推理模型 - if has_thinking { - if let Some(m) = env - .get("ANTHROPIC_REASONING_MODEL") - .and_then(|v| v.as_str()) - { - log::debug!("[Transform] 使用推理模型: {m}"); - return m.to_string(); - } - } - - // 根据模型类型选择配置模型 - if model_lower.contains("haiku") { - if let Some(m) = env - .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") - .and_then(|v| v.as_str()) - { - return m.to_string(); - } - } - if model_lower.contains("opus") { - if let Some(m) = env - .get("ANTHROPIC_DEFAULT_OPUS_MODEL") - .and_then(|v| v.as_str()) - { - return m.to_string(); - } - } - if model_lower.contains("sonnet") { - if let Some(m) = env - .get("ANTHROPIC_DEFAULT_SONNET_MODEL") - .and_then(|v| v.as_str()) - { - return m.to_string(); - } - } - // 默认使用 ANTHROPIC_MODEL - if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) { - return m.to_string(); - } - } - - model.to_string() + settings + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()) + .or_else(|| settings.get("base_url").and_then(|v| v.as_str())) + .or_else(|| settings.get("baseURL").and_then(|v| v.as_str())) + .or_else(|| settings.get("apiBaseUrl").and_then(|v| v.as_str())) + .or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str())) } /// Anthropic 请求 → OpenAI 请求 pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result { let mut result = json!({}); - // 模型映射:使用 Provider 配置中的模型(支持 thinking 参数) + // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 if let Some(model) = body.get("model").and_then(|m| m.as_str()) { - let mapped_model = get_model_from_provider(model, provider, &body); - result["model"] = json!(mapped_model); + result["model"] = json!(model); } let mut messages = Vec::new(); @@ -107,7 +60,30 @@ pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result { + let clamped = max_tokens.clamp(DEEPSEEK_MAX_TOKENS_MIN, DEEPSEEK_MAX_TOKENS_MAX); + if clamped != max_tokens { + log::warn!( + "[Transform] DeepSeek max_tokens 超出范围,已自动调整: {max_tokens} → {clamped}" + ); + } + result["max_tokens"] = json!(clamped); + } + _ => { + // 非 DeepSeek / 非数字类型:保持原样,让上游自行处理 + result["max_tokens"] = v.clone(); + } + } } if let Some(v) = body.get("temperature") { result["temperature"] = v.clone(); @@ -418,8 +394,7 @@ mod tests { }); let result = anthropic_to_openai(input, &provider).unwrap(); - // opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL - assert_eq!(result["model"], "anthropic/claude-opus-4.5"); + assert_eq!(result["model"], "claude-3-opus"); assert_eq!(result["max_tokens"], 1024); assert_eq!(result["messages"][0]["role"], "user"); assert_eq!(result["messages"][0]["content"], "Hello"); @@ -564,77 +539,35 @@ mod tests { #[test] fn test_model_mapping_from_provider() { - let provider = create_openrouter_provider(); - let body = json!({"model": "test"}); + let provider = create_provider(json!({ + "ANTHROPIC_MODEL": "gpt-4o-mini", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-4o" + })); - // sonnet 模型 - assert_eq!( - get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body), - "anthropic/claude-sonnet-4.5" - ); - - // haiku 模型 - assert_eq!( - get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body), - "anthropic/claude-haiku-4.5" - ); - - // opus 模型 - assert_eq!( - get_model_from_provider("claude-opus-4-5", &provider, &body), - "anthropic/claude-opus-4.5" - ); - } - - #[test] - fn test_anthropic_to_openai_model_mapping() { - let provider = create_openrouter_provider(); + // 回归:格式转换层不能再二次做模型映射,否则会把已映射的 model 覆盖成默认模型 let input = json!({ - "model": "claude-sonnet-4-5-20250929", + "model": "gpt-4o", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }); let result = anthropic_to_openai(input, &provider).unwrap(); - assert_eq!(result["model"], "anthropic/claude-sonnet-4.5"); + assert_eq!(result["model"], "gpt-4o"); } #[test] - fn test_thinking_parameter_detection() { - let mut provider = create_openrouter_provider(); - // 添加推理模型配置 - if let Some(env) = provider.settings_config.get_mut("env") { - env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended"); - } + fn test_deepseek_max_tokens_clamp() { + let provider = create_provider(json!({ + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1", + })); let input = json!({ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "thinking": {"type": "enabled"}, - "messages": [{"role": "user", "content": "Solve this problem"}] - }); - - let result = anthropic_to_openai(input, &provider).unwrap(); - // 应该使用推理模型 - assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended"); - } - - #[test] - fn test_thinking_parameter_disabled() { - let mut provider = create_openrouter_provider(); - if let Some(env) = provider.settings_config.get_mut("env") { - env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended"); - } - - let input = json!({ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "thinking": {"type": "disabled"}, + "model": "deepseek-chat", + "max_tokens": 20000, "messages": [{"role": "user", "content": "Hello"}] }); let result = anthropic_to_openai(input, &provider).unwrap(); - // 应该使用普通模型 - assert_eq!(result["model"], "anthropic/claude-sonnet-4.5"); + assert_eq!(result["max_tokens"], 8192); } } From 162800e18e88fe71834cc349b36c9cfe7d5c3ad4 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 30 Jan 2026 15:22:39 +0800 Subject: [PATCH 07/18] feat(claude): add apiFormat support for provider presets Allow preset providers to specify API format (anthropic or openai_chat), enabling third-party proxy services that use OpenAI Chat Completions format. --- src/components/providers/forms/ProviderForm.tsx | 8 ++++++++ src/config/claudeProviderPresets.ts | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index e2258e8ff..c05dd38b3 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1139,6 +1139,14 @@ export function ProviderForm({ preset.templateValues, ); + // Sync preset's apiFormat to local state (for Claude providers) + if (preset.apiFormat) { + setLocalApiFormat(preset.apiFormat); + } else { + // Reset to default if preset doesn't specify apiFormat + setLocalApiFormat("anthropic"); + } + form.reset({ name: preset.name, websiteUrl: preset.websiteUrl ?? "", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index bb6abc479..0b117c4ac 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -43,6 +43,11 @@ export interface ProviderPreset { // 图标配置 icon?: string; // 图标名称 iconColor?: string; // 图标颜色 + + // Claude API 格式(仅 Claude 供应商使用) + // - "anthropic" (默认): Anthropic Messages API 格式,直接透传 + // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + apiFormat?: "anthropic" | "openai_chat"; } export const providerPresets: ProviderPreset[] = [ From 78e341ccb980008379e5efb4c7f956903fbb041c Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 30 Jan 2026 15:39:55 +0800 Subject: [PATCH 08/18] feat(providers): add NVIDIA NIM preset for Claude and OpenCode - Add NVIDIA NIM provider preset with API configuration - Add nvidia.svg icon and register in icon system - Add nvidia metadata with keywords and default color (#74B71B) --- src/config/claudeProviderPresets.ts | 19 +++++++++++++++++++ src/config/opencodeProviderPresets.ts | 26 ++++++++++++++++++++++++++ src/icons/extracted/index.ts | 1 + src/icons/extracted/metadata.ts | 7 +++++++ src/icons/extracted/nvidia.svg | 1 + 5 files changed, 54 insertions(+) create mode 100644 src/icons/extracted/nvidia.svg diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 0b117c4ac..6a3fa0145 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -484,6 +484,25 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "Nvidia", + websiteUrl: "https://build.nvidia.com", + apiKeyUrl: "https://build.nvidia.com/settings/api-keys", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://integrate.api.nvidia.com", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "moonshotai/kimi-k2.5", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "moonshotai/kimi-k2.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "moonshotai/kimi-k2.5", + ANTHROPIC_DEFAULT_OPUS_MODEL: "moonshotai/kimi-k2.5", + }, + }, + category: "aggregator", + apiFormat: "openai_chat", + icon: "nvidia", + iconColor: "#000000", + }, { name: "Xiaomi MiMo", websiteUrl: "https://platform.xiaomimimo.com", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 7dd3d6337..7946c5557 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -558,6 +558,32 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "Nvidia", + websiteUrl: "https://build.nvidia.com", + apiKeyUrl: "https://build.nvidia.com/settings/api-keys", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "Nvidia", + options: { + baseURL: "https://integrate.api.nvidia.com/v1", + apiKey: "", + }, + models: { + "moonshotai/kimi-k2.5": { name: "Kimi K2.5" }, + }, + }, + category: "aggregator", + icon: "nvidia", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, // ========== 第三方合作伙伴 ========== { diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 2e451d36f..a2afa85e5 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -58,6 +58,7 @@ export const icons: Record = { siliconflow: `SiliconCloud`, catcoder: `KwaiKAT`, mcp: `ModelContextProtocol`, + nvidia: `Nvidia`, }; export const iconList = Object.keys(icons); diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index a8f678da9..4a79f2201 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -359,6 +359,13 @@ export const iconMetadata: Record = { keywords: ["xiaomimimo", "xiaomi", "mimo"], defaultColor: "#000000", }, + nvidia: { + name: "nvidia", + displayName: "NVIDIA", + category: "ai-provider", + keywords: ["nvidia", "nim", "gpu"], + defaultColor: "#74B71B", + }, }; export function getIconMetadata(name: string): IconMetadata | undefined { diff --git a/src/icons/extracted/nvidia.svg b/src/icons/extracted/nvidia.svg new file mode 100644 index 000000000..a9683c2e0 --- /dev/null +++ b/src/icons/extracted/nvidia.svg @@ -0,0 +1 @@ +Nvidia \ No newline at end of file From 1ed122a8bdcfdb61463d5ad43bfa3bfde8038557 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 30 Jan 2026 16:30:36 +0800 Subject: [PATCH 09/18] refactor(proxy): remove DeepSeek max_tokens clamp from transform layer The max_tokens restriction was too aggressive and should be handled upstream or by the provider itself. Simplify anthropic_to_openai by removing provider parameter since model mapping is already done by proxy::model_mapper. --- src-tauri/src/proxy/providers/claude.rs | 4 +- src-tauri/src/proxy/providers/transform.rs | 110 ++------------------- 2 files changed, 12 insertions(+), 102 deletions(-) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 59d0da43f..ac8b2c8e4 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -305,9 +305,9 @@ impl ProviderAdapter for ClaudeAdapter { fn transform_request( &self, body: serde_json::Value, - provider: &Provider, + _provider: &Provider, ) -> Result { - super::transform::anthropic_to_openai(body, provider) + super::transform::anthropic_to_openai(body) } fn transform_response(&self, body: serde_json::Value) -> Result { diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index a73d77f28..414debcf8 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -3,25 +3,11 @@ //! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持 //! 参考: anthropic-proxy-rs -use crate::provider::Provider; use crate::proxy::error::ProxyError; use serde_json::{json, Value}; -fn extract_base_url_from_provider(provider: &Provider) -> Option<&str> { - let settings = &provider.settings_config; - - settings - .get("env") - .and_then(|env| env.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()) - .or_else(|| settings.get("base_url").and_then(|v| v.as_str())) - .or_else(|| settings.get("baseURL").and_then(|v| v.as_str())) - .or_else(|| settings.get("apiBaseUrl").and_then(|v| v.as_str())) - .or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str())) -} - /// Anthropic 请求 → OpenAI 请求 -pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result { +pub fn anthropic_to_openai(body: Value) -> Result { let mut result = json!({}); // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 @@ -60,30 +46,7 @@ pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result { - let clamped = max_tokens.clamp(DEEPSEEK_MAX_TOKENS_MIN, DEEPSEEK_MAX_TOKENS_MAX); - if clamped != max_tokens { - log::warn!( - "[Transform] DeepSeek max_tokens 超出范围,已自动调整: {max_tokens} → {clamped}" - ); - } - result["max_tokens"] = json!(clamped); - } - _ => { - // 非 DeepSeek / 非数字类型:保持原样,让上游自行处理 - result["max_tokens"] = v.clone(); - } - } + result["max_tokens"] = v.clone(); } if let Some(v) = body.get("temperature") { result["temperature"] = v.clone(); @@ -357,43 +320,15 @@ pub fn openai_to_anthropic(body: Value) -> Result { mod tests { use super::*; - fn create_provider(env_config: Value) -> Provider { - Provider { - id: "test".to_string(), - name: "Test Provider".to_string(), - settings_config: json!({"env": env_config}), - website_url: None, - category: None, - created_at: None, - sort_index: None, - notes: None, - meta: None, - icon: None, - icon_color: None, - in_failover_queue: false, - } - } - - fn create_openrouter_provider() -> Provider { - create_provider(json!({ - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", - "ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5" - })) - } - #[test] fn test_anthropic_to_openai_simple() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["model"], "claude-3-opus"); assert_eq!(result["max_tokens"], 1024); assert_eq!(result["messages"][0]["role"], "user"); @@ -402,7 +337,6 @@ mod tests { #[test] fn test_anthropic_to_openai_with_system() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-sonnet", "max_tokens": 1024, @@ -410,7 +344,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); assert_eq!( result["messages"][0]["content"], @@ -421,7 +355,6 @@ mod tests { #[test] fn test_anthropic_to_openai_with_tools() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -433,14 +366,13 @@ mod tests { }] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); } #[test] fn test_anthropic_to_openai_tool_use() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -453,7 +385,7 @@ mod tests { }] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); let msg = &result["messages"][0]; assert_eq!(msg["role"], "assistant"); assert!(msg.get("tool_calls").is_some()); @@ -462,7 +394,6 @@ mod tests { #[test] fn test_anthropic_to_openai_tool_result() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -474,7 +405,7 @@ mod tests { }] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); let msg = &result["messages"][0]; assert_eq!(msg["role"], "tool"); assert_eq!(msg["tool_call_id"], "call_123"); @@ -538,36 +469,15 @@ mod tests { } #[test] - fn test_model_mapping_from_provider() { - let provider = create_provider(json!({ - "ANTHROPIC_MODEL": "gpt-4o-mini", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-4o" - })); - - // 回归:格式转换层不能再二次做模型映射,否则会把已映射的 model 覆盖成默认模型 + fn test_model_passthrough() { + // 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理 let input = json!({ "model": "gpt-4o", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["model"], "gpt-4o"); } - - #[test] - fn test_deepseek_max_tokens_clamp() { - let provider = create_provider(json!({ - "ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1", - })); - - let input = json!({ - "model": "deepseek-chat", - "max_tokens": 20000, - "messages": [{"role": "user", "content": "Hello"}] - }); - - let result = anthropic_to_openai(input, &provider).unwrap(); - assert_eq!(result["max_tokens"], 8192); - } } From 57713dd336c65ab12f36edbd4836c21883fcb690 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 30 Jan 2026 16:33:49 +0800 Subject: [PATCH 10/18] feat(claude): show proxy hint when switching to OpenAI Chat format provider Display an info toast when switching to a provider that uses OpenAI Chat format (apiFormat === "openai_chat"), reminding users to enable the proxy service. Move toast logic from mutation to useProviderActions for better control over notification content based on provider properties. --- src/hooks/useProviderActions.ts | 36 +++++++++++++++++++++++++++++++-- src/i18n/locales/en.json | 3 ++- src/i18n/locales/ja.json | 3 ++- src/i18n/locales/zh.json | 3 ++- src/lib/query/mutations.ts | 13 ++---------- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index cc46cca89..ede55f8a2 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -84,11 +84,43 @@ export function useProviderActions(activeApp: AppId) { try { await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); + + // 根据供应商类型显示不同的成功提示 + if ( + activeApp === "claude" && + provider.category !== "official" && + provider.meta?.apiFormat === "openai_chat" + ) { + // OpenAI Chat 格式供应商:显示代理提示 + toast.info( + t("notifications.openAIChatFormatHint", { + defaultValue: + "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", + }), + { + duration: 5000, + closeButton: true, + }, + ); + } else { + // 普通供应商:显示切换成功 + // OpenCode: show "added to config" message instead of "switched" + const messageKey = + activeApp === "opencode" + ? "notifications.addToConfigSuccess" + : "notifications.switchSuccess"; + const defaultMessage = + activeApp === "opencode" ? "已添加到配置" : "切换成功!"; + + toast.success(t(messageKey, { defaultValue: defaultMessage }), { + closeButton: true, + }); + } } catch { - // 错误提示由 mutation 与同步函数处理 + // 错误提示由 mutation 处理 } }, - [switchProviderMutation, syncClaudePlugin], + [switchProviderMutation, syncClaudePlugin, activeApp, t], ); // 删除供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b0d10c987..d3df05da6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -155,7 +155,8 @@ "deleteSuccess": "Provider deleted", "deleteFailed": "Failed to delete provider: {{error}}", "settingsSaved": "Settings saved", - "settingsSaveFailed": "Failed to save settings: {{error}}" + "settingsSaveFailed": "Failed to save settings: {{error}}", + "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled" }, "confirm": { "deleteProvider": "Delete Provider", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5f028a8cc..ca053f2d3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -155,7 +155,8 @@ "deleteSuccess": "プロバイダーを削除しました", "deleteFailed": "プロバイダーの削除に失敗しました: {{error}}", "settingsSaved": "設定を保存しました", - "settingsSaveFailed": "設定の保存に失敗しました: {{error}}" + "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", + "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です" }, "confirm": { "deleteProvider": "プロバイダーを削除", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5126f5824..cb16c0f0d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -155,7 +155,8 @@ "deleteSuccess": "供应商已删除", "deleteFailed": "删除供应商失败:{{error}}", "settingsSaved": "设置已保存", - "settingsSaveFailed": "保存设置失败:{{error}}" + "settingsSaveFailed": "保存设置失败:{{error}}", + "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用" }, "confirm": { "deleteProvider": "删除供应商", diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 0aa84356d..454dd54d4 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -171,17 +171,8 @@ export const useSwitchProviderMutation = (appId: AppId) => { ); } - // OpenCode: show "added to config" message instead of "switched" - const messageKey = - appId === "opencode" - ? "notifications.addToConfigSuccess" - : "notifications.switchSuccess"; - const defaultMessage = - appId === "opencode" ? "已添加到配置" : "切换供应商成功"; - - toast.success(t(messageKey, { defaultValue: defaultMessage }), { - closeButton: true, - }); + // Note: Success toast is handled by useProviderActions.switchProvider + // to allow customization based on provider properties (e.g., apiFormat) }, onError: (error: Error) => { const detail = extractErrorMessage(error) || t("common.unknown"); From 05c21e016fbd940064bd4dec49c6b26b7e9d2fc6 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 30 Jan 2026 09:41:08 +0800 Subject: [PATCH 11/18] feat(settings): add OpenCode support to environment check and one-click install - Add OpenCode version detection with Go path scanning - Add GitHub Releases API for fetching latest OpenCode version - Add OpenCode install command to one-click install section - Update i18n hints to include OpenCode across all locales - Fix SettingsPage indentation formatting --- src-tauri/src/commands/misc.rs | 37 +- src/components/settings/AboutSection.tsx | 18 +- src/components/settings/SettingsPage.tsx | 737 +++++++++++------------ src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- 6 files changed, 416 insertions(+), 382 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 1d2124aef..f848fe6bf 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -89,7 +89,7 @@ pub struct ToolVersion { #[tauri::command] pub async fn get_tool_versions() -> Result, String> { - let tools = vec!["claude", "codex", "gemini"]; + let tools = vec!["claude", "codex", "gemini", "opencode"]; let mut results = Vec::new(); // 使用全局 HTTP 客户端(已包含代理配置) @@ -116,6 +116,7 @@ pub async fn get_tool_versions() -> Result, String> { "claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await, "codex" => fetch_npm_latest_version(&client, "@openai/codex").await, "gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, + "opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await, _ => None, }; @@ -148,6 +149,29 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op } } +/// Helper function to fetch latest version from GitHub releases +async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option { + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + match client + .get(&url) + .header("User-Agent", "cc-switch") + .header("Accept", "application/vnd.github+json") + .send() + .await + { + Ok(resp) => { + if let Ok(json) = resp.json::().await { + json.get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.strip_prefix('v').unwrap_or(s).to_string()) + } else { + None + } + } + Err(_) => None, + } +} + /// 预编译的版本号正则表达式 static VERSION_RE: Lazy = Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex")); @@ -224,7 +248,7 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option, Option (Option, Option) { } } + // 添加 Go 路径支持 (opencode 使用 go install 安装) + if tool == "opencode" { + search_paths.push(home.join("go/bin")); // go install 默认路径 + if let Ok(gopath) = std::env::var("GOPATH") { + search_paths.push(std::path::PathBuf::from(gopath).join("bin")); + } + } + // 在每个路径中查找工具 for path in &search_paths { let tool_path = if cfg!(target_os = "windows") { @@ -405,6 +437,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option { "claude" => crate::settings::get_claude_override_dir(), "codex" => crate::settings::get_codex_override_dir(), "gemini" => crate::settings::get_gemini_override_dir(), + "opencode" => crate::settings::get_opencode_override_dir(), _ => None, }?; diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index f301e2fbe..dab867d92 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -37,7 +37,9 @@ curl -fsSL https://claude.ai/install.sh | bash # Codex npm i -g @openai/codex@latest # Gemini CLI -npm i -g @google/gemini-cli@latest`; +npm i -g @google/gemini-cli@latest +# OpenCode +curl -fsSL https://opencode.ai/install | bash`; export function AboutSection({ isPortable }: AboutSectionProps) { // ... (use hooks as before) ... @@ -315,10 +317,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) { {isLoadingTools ? t("common.refreshing") : t("common.refresh")}
-
- {["claude", "codex", "gemini"].map((toolName, index) => { +
+ {["claude", "codex", "gemini", "opencode"].map((toolName, index) => { const tool = toolVersions.find((item) => item.name === toolName); - const displayName = tool?.name ?? toolName; + // Special case for OpenCode (capital C), others use capitalize + const displayName = + toolName === "opencode" + ? "OpenCode" + : toolName.charAt(0).toUpperCase() + toolName.slice(1); const title = tool?.version || tool?.error || t("common.unknown"); return ( @@ -333,9 +339,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
- - {displayName} - + {displayName}
{isLoadingTools ? ( diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ebd61a9a1..6f339610f 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -232,405 +232,405 @@ export function SettingsPage({
- {settings ? ( - - handleAutoSave({ language: lang })} - /> - - - - - handleAutoSave({ skillSyncMethod: method }) - } - /> - - handleAutoSave({ preferredTerminal: terminal }) - } - /> - - ) : null} - - - - {settings ? ( - - - - -
- -
-

- {t("settings.advanced.configDir.title")} -

-

- {t("settings.advanced.configDir.description")} -

-
-
-
- - - -
+ handleAutoSave({ language: lang })} + /> + + + + + handleAutoSave({ skillSyncMethod: method }) + } + /> + + handleAutoSave({ preferredTerminal: terminal }) + } + /> +
+ ) : null} +
- + {settings ? ( + + - - + +
- +

- {t("settings.advanced.proxy.title")} + {t("settings.advanced.configDir.title")}

- {t("settings.advanced.proxy.description")} + {t("settings.advanced.configDir.description")}

- -
- -
- - - {isRunning - ? t("settings.advanced.proxy.running") - : t("settings.advanced.proxy.stopped")} - - + + -
-
- - - -
+ + - - -
- -
-

- {t("settings.advanced.failover.title")} -

-

- {t("settings.advanced.failover.description")} -

+ + + +
+ +
+

+ {t("settings.advanced.proxy.title")} +

+

+ {t("settings.advanced.proxy.description")} +

+
+
+ +
+ +
+ + + {isRunning + ? t("settings.advanced.proxy.running") + : t("settings.advanced.proxy.stopped")} + +
-
- - -
- {/* 代理未运行时的提示 */} - {!isRunning && ( -
-

- {t("proxy.failover.proxyRequired", { - defaultValue: - "需要先启动代理服务才能配置故障转移", - })} + + + + + + + + +

+ +
+

+ {t("settings.advanced.failover.title")} +

+

+ {t("settings.advanced.failover.description")}

- )} +
+ + +
+ {/* 代理未运行时的提示 */} + {!isRunning && ( +
+

+ {t("proxy.failover.proxyRequired", { + defaultValue: + "需要先启动代理服务才能配置故障转移", + })} +

+
+ )} - {/* 故障转移设置 - 按应用分组 */} - - - Claude - Codex - Gemini - - -
-
-

- {t("proxy.failoverQueue.title")} -

-

- {t("proxy.failoverQueue.description")} -

+ {/* 故障转移设置 - 按应用分组 */} + + + Claude + Codex + Gemini + + +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
+
- -
-
- -
- - -
-
-

- {t("proxy.failoverQueue.title")} -

-

- {t("proxy.failoverQueue.description")} -

+
+
- -
-
- -
- - -
-
-

- {t("proxy.failoverQueue.title")} -

-

- {t("proxy.failoverQueue.description")} -

+ + +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
+
- -
-
- -
- - -
- - - - - -
- -
-

- {t("settings.advanced.rectifier.title")} -

-

- {t("settings.advanced.rectifier.description")} -

+
+ +
+ + +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
+ +
+
+ +
+
+
-
-
- - - -
+ + - - -
- -
-

- {t("settings.advanced.modelTest.title")} -

-

- {t("settings.advanced.modelTest.description")} -

+ + +
+ +
+

+ {t("settings.advanced.rectifier.title")} +

+

+ {t("settings.advanced.rectifier.description")} +

+
-
- - - - - + + + + + - - -
- -
-

- {t("settings.advanced.pricing.title")} -

-

- {t("settings.advanced.pricing.description")} -

+ + +
+ +
+

+ {t("settings.advanced.modelTest.title")} +

+

+ {t("settings.advanced.modelTest.description")} +

+
-
- - - - - + + + + + - - -
- -
-

- {t("settings.advanced.globalProxy.title")} -

-

- {t("settings.advanced.globalProxy.description")} -

+ + +
+ +
+

+ {t("settings.advanced.pricing.title")} +

+

+ {t("settings.advanced.pricing.description")} +

+
-
- - - - - + + + + + - - -
- -
-

- {t("settings.advanced.data.title")} -

-

- {t("settings.advanced.data.description")} -

+ + +
+ +
+

+ {t("settings.advanced.globalProxy.title")} +

+

+ {t("settings.advanced.globalProxy.description")} +

+
-
- - - - - + + + + + - - -
- -
-

- {t("settings.advanced.logConfig.title")} -

-

- {t("settings.advanced.logConfig.description")} -

+ + +
+ +
+

+ {t("settings.advanced.data.title")} +

+

+ {t("settings.advanced.data.description")} +

+
-
- - - - - - - - ) : null} - + + + + + - - - + + +
+ +
+

+ {t("settings.advanced.logConfig.title")} +

+

+ {t("settings.advanced.logConfig.description")} +

+
+
+
+ + + +
+ + + ) : null} + - - - + + + + + + +
{activeTab === "advanced" && settings && ( @@ -639,10 +639,7 @@ export function SettingsPage({ style={{ backgroundColor: "hsl(var(--background))" }} >
-