diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 2bc62982..cbeee21d 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 3d6fbb1f..195b2be0 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 229eebdf..a226e93a 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 02e55c0a..52437e6d 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 8080d84b..71f6cbe1 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 b22043ad..0f429b03 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 5db01015..e2258e8f 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 25ef4b64..2416e373 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 同步方式