mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
merge: resolve conflicts between fix/common-config-sync-improvements and main
Merged both feature sets: - common-config: commonConfigEnabled, commonConfigEnabledByApp fields - api-format: apiFormat field for Claude provider OpenAI compatibility Key resolution in live.rs: apply sanitize_claude_settings_for_live() to merged config before writing to ensure internal fields are stripped.
This commit is contained in:
@@ -89,7 +89,7 @@ pub struct ToolVersion {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, 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<Vec<ToolVersion>, 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<String> {
|
||||
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::<serde_json::Value>().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<Regex> =
|
||||
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<String>, Option<Stri
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
debug_assert!(
|
||||
["claude", "codex", "gemini"].contains(&tool),
|
||||
["claude", "codex", "gemini", "opencode"].contains(&tool),
|
||||
"unexpected tool name: {tool}"
|
||||
);
|
||||
|
||||
@@ -348,6 +372,14 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 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<String> {
|
||||
"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,
|
||||
}?;
|
||||
|
||||
|
||||
+48
-48
@@ -253,6 +253,11 @@ pub struct ProviderMeta {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled_by_app: Option<CommonConfigEnabledByApp>,
|
||||
/// 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 {
|
||||
@@ -551,54 +556,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 供应商配置结构
|
||||
// ============================================================================
|
||||
@@ -958,4 +915,47 @@ mod provider_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<none>");
|
||||
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
|
||||
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||
log::debug!(
|
||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||
|
||||
@@ -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,22 +52,52 @@ impl ClaudeAdapter {
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测 OpenRouter 是否启用兼容模式
|
||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
||||
if !self.is_openrouter(provider) {
|
||||
return false;
|
||||
/// 获取 API 格式
|
||||
///
|
||||
/// 从 provider.meta.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
// 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 if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
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();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,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
|
||||
@@ -253,21 +295,19 @@ 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(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
_provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::anthropic_to_openai(body, provider)
|
||||
super::transform::anthropic_to_openai(body)
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
@@ -278,6 +318,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 {
|
||||
@@ -297,6 +338,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();
|
||||
@@ -459,6 +517,7 @@ mod tests {
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// Default: no transform (anthropic format) - no meta
|
||||
let anthropic_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
@@ -466,29 +525,96 @@ 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!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
||||
// 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()
|
||||
},
|
||||
);
|
||||
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||||
|
||||
// OpenRouter provider with explicit compat mode enabled should transform
|
||||
let openrouter_enabled = 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://openrouter.ai/api"
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"api_format": "openai_chat"
|
||||
}));
|
||||
assert!(adapter.needs_transform(&legacy_settings_api_format));
|
||||
|
||||
// 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(&openrouter_enabled));
|
||||
assert!(adapter.needs_transform(&legacy_openrouter_bool));
|
||||
|
||||
let openrouter_disabled = create_provider(json!({
|
||||
let legacy_openrouter_num = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"openrouter_compat_mode": false
|
||||
"openrouter_compat_mode": 1
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_disabled));
|
||||
assert!(adapter.needs_transform(&legacy_openrouter_num));
|
||||
|
||||
let legacy_openrouter_str = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"openrouter_compat_mode": "true"
|
||||
}));
|
||||
assert!(adapter.needs_transform(&legacy_openrouter_str));
|
||||
|
||||
// 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()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||
|
||||
// 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()
|
||||
},
|
||||
);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,77 +3,16 @@
|
||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||
//! 参考: anthropic-proxy-rs
|
||||
|
||||
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();
|
||||
|
||||
// 检测 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()
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
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();
|
||||
@@ -381,45 +320,16 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
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();
|
||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
||||
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");
|
||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||
@@ -427,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,
|
||||
@@ -435,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"],
|
||||
@@ -446,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,
|
||||
@@ -458,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,
|
||||
@@ -478,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());
|
||||
@@ -487,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,
|
||||
@@ -499,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");
|
||||
@@ -563,78 +469,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_mapping_from_provider() {
|
||||
let provider = create_openrouter_provider();
|
||||
let body = json!({"model": "test"});
|
||||
|
||||
// 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();
|
||||
fn test_model_passthrough() {
|
||||
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
|
||||
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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
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"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// 应该使用普通模型
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,6 +28,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)]
|
||||
@@ -163,7 +175,8 @@ fn write_live_snapshot_internal(
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let path = get_claude_settings_path();
|
||||
write_json_file(&path, config_to_write)?;
|
||||
let settings = sanitize_claude_settings_for_live(config_to_write);
|
||||
write_json_file(&path, &settings)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let obj = config_to_write.as_object().ok_or_else(|| {
|
||||
|
||||
@@ -29,6 +29,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, write_live_snapshot_with_merge};
|
||||
|
||||
// Internal re-exports
|
||||
|
||||
@@ -1699,7 +1699,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> {
|
||||
|
||||
Reference in New Issue
Block a user