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:
YoVinchen
2026-02-05 14:30:15 +08:00
parent 7f6ce72a88
commit b12d12790b
12 changed files with 640 additions and 181 deletions
+1
View File
@@ -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等模块需要)
+350
View File
@@ -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 格式不是全链接
}
}