mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
438 lines
14 KiB
Rust
438 lines
14 KiB
Rust
//! URL 构建工具模块
|
|
//!
|
|
//! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。
|
|
|
|
use crate::app_config::AppType;
|
|
use crate::proxy::providers::{ClaudeAdapter, CodexAdapter, ProviderAdapter};
|
|
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
|
|
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",
|
|
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
|
|
full_url_patterns: &[
|
|
"/v1/messages",
|
|
"/messages",
|
|
"/v1/chat/completions",
|
|
"/chat/completions",
|
|
],
|
|
}
|
|
} else {
|
|
Self {
|
|
direct_endpoint: "/v1/messages",
|
|
proxy_endpoint: "/v1/messages",
|
|
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
|
|
full_url_patterns: &[
|
|
"/v1/messages",
|
|
"/messages",
|
|
"/v1/chat/completions",
|
|
"/chat/completions",
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
fn for_codex(api_format: Option<&str>) -> Self {
|
|
let _ = api_format;
|
|
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 (base, _) = split_url_suffix(url);
|
|
let path_part = base.trim_end_matches('/').to_lowercase();
|
|
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, suffix) = split_url_suffix(base_url);
|
|
let base_trimmed = base.trim_end_matches('/');
|
|
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
|
|
|
// 直接拼接,不做任何去重
|
|
format!("{base_trimmed}/{endpoint_trimmed}{suffix}")
|
|
}
|
|
|
|
/// 智能构建 URL(用于代理地址)
|
|
///
|
|
/// 如果 base_url 已经以 API 路径结尾,直接返回;否则追加 endpoint。
|
|
pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str]) -> String {
|
|
let (base, suffix) = split_url_suffix(base_url);
|
|
let base_trimmed = base.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 format!("{base_trimmed}{suffix}");
|
|
}
|
|
|
|
// 拼接 URL
|
|
let url = format!("{base_trimmed}/{endpoint_trimmed}");
|
|
let url = dedup_v1_v1_boundary_safe(url);
|
|
format!("{url}{suffix}")
|
|
}
|
|
|
|
fn build_runtime_like_url(
|
|
app_type: &AppType,
|
|
base_url: &str,
|
|
endpoint: &str,
|
|
is_proxy: bool,
|
|
) -> String {
|
|
match app_type {
|
|
// Claude 代理预览需要展示与运行时一致的 URL 归一化结果
|
|
AppType::Claude if is_proxy => ClaudeAdapter::new().build_url(base_url, endpoint),
|
|
// Codex/OpenCode 预览复用运行时 /v1 归一化规则
|
|
AppType::Codex | AppType::OpenCode | AppType::OpenClaw => {
|
|
CodexAdapter::new().build_url(base_url, endpoint)
|
|
}
|
|
_ => build_direct_url(base_url, endpoint),
|
|
}
|
|
}
|
|
|
|
/// 构建 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 逻辑
|
|
AppType::OpenClaw => ApiPathPatterns::for_codex(api_format), // OpenClaw 使用 Codex 逻辑
|
|
};
|
|
|
|
let is_full_url = url_ends_with_api_path(base_url, patterns.full_url_patterns);
|
|
|
|
// 直连地址:默认硬拼接;Codex/OpenCode 复用运行时规则(含 origin-only /v1 归一化)
|
|
let direct_url = build_runtime_like_url(app_type, base_url, patterns.direct_endpoint, false);
|
|
// 代理地址:Claude/Codex/OpenCode 复用运行时规则;Gemini 继续使用通用智能拼接
|
|
let proxy_url = match app_type {
|
|
AppType::Claude | AppType::Codex | AppType::OpenCode | AppType::OpenClaw => {
|
|
build_runtime_like_url(app_type, base_url, patterns.proxy_endpoint, true)
|
|
}
|
|
_ => 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");
|
|
}
|
|
|
|
// base_url 缺失时无法做 full_url / url_mismatch 判断,避免误判
|
|
if base_url.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
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",
|
|
"/v1/chat/completions",
|
|
"/chat/completions",
|
|
],
|
|
AppType::Codex | AppType::OpenCode | AppType::OpenClaw => &["/v1/responses", "/responses"],
|
|
_ => return None,
|
|
};
|
|
|
|
if url_ends_with_api_path(base_url, direct_suffixes) {
|
|
return Some("full_url");
|
|
}
|
|
}
|
|
|
|
// 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异)
|
|
let (direct_base, _) = split_url_suffix(&preview.direct_url);
|
|
let (proxy_base, _) = split_url_suffix(&preview.proxy_url);
|
|
if direct_base != proxy_base {
|
|
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_origin_normalizes_v1() {
|
|
let preview =
|
|
build_url_preview(&AppType::Codex, "https://api.openai.com", 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");
|
|
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_codex_origin_none() {
|
|
let result =
|
|
check_proxy_requirement(&AppType::Codex, "https://api.openai.com", Some("responses"));
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[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_query_suffix_preserved_in_preview() {
|
|
let preview = build_url_preview(
|
|
&AppType::Claude,
|
|
"https://api.example.com?beta=true",
|
|
Some("anthropic"),
|
|
);
|
|
assert_eq!(
|
|
preview.direct_url,
|
|
"https://api.example.com/v1/messages?beta=true"
|
|
);
|
|
assert_eq!(
|
|
preview.proxy_url,
|
|
"https://api.example.com/v1/messages?beta=true"
|
|
);
|
|
assert!(!preview.is_full_url);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fragment_suffix_preserved_and_full_url_detected() {
|
|
let preview = build_url_preview(
|
|
&AppType::Claude,
|
|
"https://api.example.com/v1/messages#frag",
|
|
Some("anthropic"),
|
|
);
|
|
assert_eq!(
|
|
preview.proxy_url,
|
|
"https://api.example.com/v1/messages#frag"
|
|
);
|
|
assert!(preview.is_full_url);
|
|
}
|
|
|
|
#[test]
|
|
fn test_claude_full_url_detection_is_api_format_agnostic() {
|
|
// 与运行时 ClaudeAdapter 一致:/messages 与 /chat/completions 都视为全链接
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|