mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
refactor(proxy): unify URL building logic in backend
Move URL preview and proxy requirement checks from frontend to a centralized Rust module. This ensures consistency between the endpoint field preview and actual proxy behavior.
This commit is contained in:
@@ -23,6 +23,7 @@ pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod url_builder;
|
||||
pub mod usage;
|
||||
|
||||
// 公开导出给外部使用(commands, services等模块需要)
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! URL 构建工具模块
|
||||
//!
|
||||
//! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// URL 预览结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UrlPreview {
|
||||
/// 直连模式请求地址
|
||||
pub direct_url: String,
|
||||
/// 代理模式请求地址
|
||||
pub proxy_url: String,
|
||||
/// 是否为全链接(base_url 已包含 API 路径)
|
||||
pub is_full_url: bool,
|
||||
}
|
||||
|
||||
/// API 路径模式
|
||||
struct ApiPathPatterns {
|
||||
/// 直连模式默认端点
|
||||
direct_endpoint: &'static str,
|
||||
/// 代理模式端点(根据 api_format 可能不同)
|
||||
proxy_endpoint: &'static str,
|
||||
/// 识别为全链接的路径后缀
|
||||
full_url_patterns: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl ApiPathPatterns {
|
||||
fn for_claude(api_format: Option<&str>) -> Self {
|
||||
// 根据 API 格式决定端点和全链接检测模式
|
||||
if api_format == Some("openai_chat") {
|
||||
Self {
|
||||
direct_endpoint: "/v1/messages",
|
||||
proxy_endpoint: "/v1/chat/completions",
|
||||
// Chat 格式只检测 /chat/completions
|
||||
full_url_patterns: &["/v1/chat/completions", "/chat/completions"],
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
direct_endpoint: "/v1/messages",
|
||||
proxy_endpoint: "/v1/messages",
|
||||
// 原生格式只检测 /messages
|
||||
full_url_patterns: &["/v1/messages", "/messages"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn for_codex(_api_format: Option<&str>) -> Self {
|
||||
// Codex 只支持 /responses 端点
|
||||
Self {
|
||||
direct_endpoint: "/responses",
|
||||
proxy_endpoint: "/responses",
|
||||
full_url_patterns: &["/v1/responses", "/responses"],
|
||||
}
|
||||
}
|
||||
|
||||
fn for_gemini() -> Self {
|
||||
Self {
|
||||
direct_endpoint: "/v1beta/models",
|
||||
proxy_endpoint: "/v1beta/models",
|
||||
full_url_patterns: &["/v1beta/models"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测 URL 是否以指定的 API 路径结尾
|
||||
fn url_ends_with_api_path(url: &str, patterns: &[&str]) -> bool {
|
||||
let trimmed = url.trim_end_matches('/').to_lowercase();
|
||||
// 移除查询参数
|
||||
let path_part = trimmed.split('?').next().unwrap_or(&trimmed);
|
||||
patterns
|
||||
.iter()
|
||||
.any(|pattern| path_part.ends_with(&pattern.to_lowercase()))
|
||||
}
|
||||
|
||||
/// 硬拼接 URL(用于直连地址)
|
||||
///
|
||||
/// 始终将 endpoint 拼接到 base_url 后面,不做任何智能检测或去重。
|
||||
fn build_direct_url(base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 直接拼接,不做任何去重
|
||||
format!("{base_trimmed}/{endpoint_trimmed}")
|
||||
}
|
||||
|
||||
/// 智能构建 URL(用于代理地址)
|
||||
///
|
||||
/// 如果 base_url 已经以 API 路径结尾,直接返回;否则追加 endpoint。
|
||||
pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str]) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 检测 base_url 是否已经以 API 路径结尾
|
||||
if url_ends_with_api_path(base_trimmed, full_url_patterns) {
|
||||
return base_trimmed.to_string();
|
||||
}
|
||||
|
||||
// 拼接 URL
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
|
||||
// 去重 /v1/v1
|
||||
while url.contains("/v1/v1") {
|
||||
url = url.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
/// 构建 URL 预览
|
||||
///
|
||||
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
|
||||
/// - 直连地址:始终硬拼接默认后缀
|
||||
/// - 代理地址:智能检测,如果已包含 API 路径则不重复拼接
|
||||
pub fn build_url_preview(
|
||||
app_type: &AppType,
|
||||
base_url: &str,
|
||||
api_format: Option<&str>,
|
||||
) -> UrlPreview {
|
||||
let patterns = match app_type {
|
||||
AppType::Claude => ApiPathPatterns::for_claude(api_format),
|
||||
AppType::Codex => ApiPathPatterns::for_codex(api_format),
|
||||
AppType::Gemini => ApiPathPatterns::for_gemini(),
|
||||
AppType::OpenCode => ApiPathPatterns::for_codex(api_format), // OpenCode 使用 Codex 逻辑
|
||||
};
|
||||
|
||||
let is_full_url = url_ends_with_api_path(base_url, patterns.full_url_patterns);
|
||||
|
||||
// 直连地址:始终硬拼接默认后缀
|
||||
let direct_url = build_direct_url(base_url, patterns.direct_endpoint);
|
||||
// 代理地址:智能检测
|
||||
let proxy_url = build_smart_url(
|
||||
base_url,
|
||||
patterns.proxy_endpoint,
|
||||
patterns.full_url_patterns,
|
||||
);
|
||||
|
||||
UrlPreview {
|
||||
direct_url,
|
||||
proxy_url,
|
||||
is_full_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否需要代理
|
||||
///
|
||||
/// 返回需要代理的原因,None 表示不需要代理
|
||||
pub fn check_proxy_requirement(
|
||||
app_type: &AppType,
|
||||
base_url: &str,
|
||||
api_format: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
// Claude OpenAI Chat 格式必须开启代理(需要格式转换)
|
||||
if matches!(app_type, AppType::Claude) && api_format == Some("openai_chat") {
|
||||
return Some("openai_chat_format");
|
||||
}
|
||||
|
||||
let preview = build_url_preview(app_type, base_url, api_format);
|
||||
|
||||
// 如果是全链接且以直连后缀结尾,需要代理
|
||||
if preview.is_full_url {
|
||||
// 检查是否以直连后缀结尾
|
||||
let direct_suffixes: &[&str] = match app_type {
|
||||
AppType::Claude => &["/v1/messages", "/messages"],
|
||||
AppType::Codex => &["/v1/responses", "/responses"],
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if url_ends_with_api_path(base_url, direct_suffixes) {
|
||||
return Some("full_url");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直连地址和代理地址不同,需要代理
|
||||
if preview.direct_url != preview.proxy_url {
|
||||
return Some("url_mismatch");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_claude_anthropic() {
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_claude_openai_chat() {
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com",
|
||||
Some("openai_chat"),
|
||||
);
|
||||
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/chat/completions"
|
||||
);
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_claude_full_url() {
|
||||
// 全链接时:直连会硬拼接后缀,代理保持原地址
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/messages",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(
|
||||
preview.direct_url,
|
||||
"https://api.example.com/v1/messages/v1/messages"
|
||||
);
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_codex_responses() {
|
||||
let preview = build_url_preview(
|
||||
&AppType::Codex,
|
||||
"https://api.openai.com/v1",
|
||||
Some("responses"),
|
||||
);
|
||||
assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses");
|
||||
assert_eq!(preview.proxy_url, "https://api.openai.com/v1/responses");
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_codex_full_url() {
|
||||
// 全链接时:直连会硬拼接后缀,代理保持原地址
|
||||
let preview = build_url_preview(
|
||||
&AppType::Codex,
|
||||
"https://api.example.com/v1/responses",
|
||||
Some("responses"),
|
||||
);
|
||||
assert_eq!(
|
||||
preview.direct_url,
|
||||
"https://api.example.com/v1/responses/responses"
|
||||
);
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/responses");
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_proxy_requirement_claude_openai_chat() {
|
||||
let result = check_proxy_requirement(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com",
|
||||
Some("openai_chat"),
|
||||
);
|
||||
assert_eq!(result, Some("openai_chat_format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_proxy_requirement_claude_full_url() {
|
||||
let result = check_proxy_requirement(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/messages",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(result, Some("full_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_proxy_requirement_codex_full_url() {
|
||||
let result = check_proxy_requirement(
|
||||
&AppType::Codex,
|
||||
"https://api.example.com/v1/responses",
|
||||
Some("responses"),
|
||||
);
|
||||
assert_eq!(result, Some("full_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_proxy_requirement_none() {
|
||||
let result = check_proxy_requirement(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_v1_dedup() {
|
||||
// 代理地址使用 build_smart_url,会去重 /v1/v1
|
||||
let url = build_smart_url("https://api.example.com/v1", "/v1/messages", &[]);
|
||||
assert_eq!(url, "https://api.example.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_url_no_dedup() {
|
||||
// 直连地址硬拼接,不做任何去重
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(preview.direct_url, "https://api.example.com/v1/v1/messages");
|
||||
// 代理地址智能拼接,会去重
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_url_detection_by_api_format() {
|
||||
// anthropic 格式:只有 /messages 结尾才是全链接
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/messages",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert!(preview.is_full_url);
|
||||
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/chat/completions",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert!(!preview.is_full_url); // /chat/completions 对于 anthropic 格式不是全链接
|
||||
|
||||
// openai_chat 格式:只有 /chat/completions 结尾才是全链接
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/chat/completions",
|
||||
Some("openai_chat"),
|
||||
);
|
||||
assert!(preview.is_full_url);
|
||||
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/messages",
|
||||
Some("openai_chat"),
|
||||
);
|
||||
assert!(!preview.is_full_url); // /messages 对于 openai_chat 格式不是全链接
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user