mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-01-31 09:43:07 +08:00
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
This commit is contained in:
@@ -230,6 +230,11 @@ pub struct ProviderMeta {
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<serde_json::Value>(&settings_path)?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value, String> {
|
||||
|
||||
@@ -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<ClaudeApiFormat>(() => {
|
||||
// Claude API Format state - stored in meta, not settingsConfig
|
||||
// Read initial value from meta.apiFormat, default to "anthropic"
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 同步方式
|
||||
|
||||
Reference in New Issue
Block a user