Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src-tauri/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,54 @@ pub struct ProviderMeta {
/// 用于多账号支持,关联到特定的 GitHub 账号
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
pub github_account_id: Option<String>,
/// 模型路由配置(支持单个 Provider 根据请求模型动态切换 API 格式和目标端点)
#[serde(rename = "modelRoutingConfig", skip_serializing_if = "Option::is_none")]
pub model_routing_config: Option<ModelRoutingConfig>,
}

/// 模型路由配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRoutingConfig {
/// 是否启用路由配置
pub enabled: bool,
/// 路由规则列表
#[serde(default)]
pub routes: Vec<ModelRoute>,
/// 兜底配置(当没有匹配的路由时使用)
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback: Option<RouteFallback>,
}

/// 单个模型路由规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRoute {
/// 源模型名称(不区分大小写匹配)
#[serde(rename = "sourceModel")]
pub source_model: String,
/// 路由目标配置
pub target: RouteTarget,
}

/// 路由目标配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteTarget {
/// 目标 Base URL
#[serde(rename = "baseUrl")]
pub base_url: String,
/// 目标 API 格式
#[serde(rename = "apiFormat")]
pub api_format: String,
/// 目标模型名称
#[serde(rename = "modelName")]
pub model_name: String,
}

/// 路由兜底配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteFallback {
/// 兜底 API 格式
#[serde(rename = "apiFormat")]
pub api_format: String,
}

impl ProviderMeta {
Expand Down
21 changes: 19 additions & 2 deletions src-tauri/src/proxy/forwarder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,9 @@ impl RequestForwarder {
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);

// 提取原始模型名称(在模型映射之前,用于模型路由)
let original_model = body.get("model").and_then(|m| m.as_str());

// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
Expand Down Expand Up @@ -861,9 +864,20 @@ impl RequestForwarder {
}
}
}

// Claude 模型路由:根据请求模型动态修改 base_url
// 只在非 Copilot 且非 full_url 模式下处理
if adapter.name() == "Claude" && !is_copilot && !is_full_url {
base_url = crate::proxy::providers::get_routed_base_url(
provider,
original_model,
&base_url,
);
Comment on lines +871 to +875
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite request model when a routing rule matches

The routing integration in forward only replaces base_url (and separately API format), but it never updates the outgoing request model to RouteTarget.model_name. As a result, routes intended to map Claude model IDs to different backend model IDs still send the original Claude model upstream, which can cause wrong-model execution or upstream rejection for non-Claude backends. The matched route’s target model needs to be applied to the request body before sending.

Useful? React with 👍 / 👎.

}

let resolved_claude_api_format = if adapter.name() == "Claude" {
Some(
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
self.resolve_claude_api_format(provider, &mapped_body, is_copilot, original_model)
.await,
)
} else {
Expand Down Expand Up @@ -1362,9 +1376,12 @@ impl RequestForwarder {
provider: &Provider,
body: &Value,
is_copilot: bool,
original_model: Option<&str>,
) -> String {
if !is_copilot {
return super::providers::get_claude_api_format(provider).to_string();
// 使用支持模型路由的 get_claude_api_format_with_model
return crate::proxy::providers::get_claude_api_format_with_model(provider, original_model)
.to_string();
}

let model = body.get("model").and_then(|value| value.as_str());
Expand Down
63 changes: 59 additions & 4 deletions src-tauri/src/proxy/providers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,45 @@ use crate::proxy::error::ProxyError;
/// 获取 Claude 供应商的 API 格式
///
/// 供 handler/forwarder 外部使用的公开函数。
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
/// 优先级:model_routing_config > meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
get_claude_api_format_with_model(provider, None)
}

/// 获取 Claude 供应商的 API 格式(支持模型路由)
///
/// # Arguments
/// * `provider` - Provider 实例
/// * `model_name` - 请求中的模型名称(可选,用于模型路由匹配)
pub fn get_claude_api_format_with_model(provider: &Provider, model_name: Option<&str>) -> &'static str {
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("codex_oauth") {
return "openai_responses";
}
}

// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
// 1) 检查 model_routing_config(支持根据请求模型动态选择 API 格式)
if let Some(meta) = provider.meta.as_ref() {
if let Some(ref routing_config) = meta.model_routing_config {
if routing_config.enabled {
if let Some(model) = model_name {
// 大小写不敏感匹配
if let Some(route) = routing_config.routes.iter().find(|r| {
r.source_model.eq_ignore_ascii_case(model)
}) {
return Box::leak(route.target.api_format.clone().into_boxed_str());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop leaking strings when selecting routed API format

get_claude_api_format_with_model now allocates and leaks memory on every matched route by returning Box::leak(...into_boxed_str()). Because this path runs per request, a busy long-lived proxy process will accumulate unreclaimed heap memory over time. This should return a non-leaking owned value (or map to known static literals) instead of manufacturing 'static strings at runtime.

Useful? React with 👍 / 👎.

}
}
// 匹配不到时使用 fallback
if let Some(ref fallback) = routing_config.fallback {
return Box::leak(fallback.api_format.clone().into_boxed_str());
}
}
}
}

// 2) 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 match api_format {
Expand All @@ -40,7 +69,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
}
}

// 2) Backward compatibility: legacy settings_config.api_format
// 3) Backward compatibility: legacy settings_config.api_format
if let Some(api_format) = provider
.settings_config
.get("api_format")
Expand All @@ -53,7 +82,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
};
}

// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
// 4) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
let raw = provider.settings_config.get("openrouter_compat_mode");
let enabled = match raw {
Some(serde_json::Value::Bool(v)) => *v,
Expand All @@ -72,6 +101,32 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
}
}

/// 根据模型路由获取目标 Base URL
///
/// # Arguments
/// * `provider` - Provider 实例
/// * `model_name` - 请求中的模型名称(用于路由匹配)
/// * `default_url` - 默认的 Base URL(Provider 配置的原始 URL)
pub fn get_routed_base_url(provider: &Provider, model_name: Option<&str>, default_url: &str) -> String {
// 检查 model_routing_config
if let Some(meta) = provider.meta.as_ref() {
if let Some(ref routing_config) = meta.model_routing_config {
if routing_config.enabled {
if let Some(model) = model_name {
// 大小写不敏感匹配
if let Some(route) = routing_config.routes.iter().find(|r| {
r.source_model.eq_ignore_ascii_case(model)
}) {
return route.target.base_url.clone();
}
}
}
}
}
// 返回默认 URL
default_url.to_string()
}

pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
matches!(api_format, "openai_chat" | "openai_responses")
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/proxy/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
pub use claude::{
claude_api_format_needs_transform, get_claude_api_format,
get_claude_api_format_with_model, get_routed_base_url,
transform_claude_request_for_api_format, ClaudeAdapter,
};
pub use codex::CodexAdapter;
Expand Down
Loading
Loading