mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +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:
@@ -407,3 +407,43 @@ pub async fn get_circuit_breaker_stats(
|
||||
let _ = (state, provider_id, app_type);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ==================== URL 预览相关命令 ====================
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::proxy::url_builder::{self, UrlPreview};
|
||||
|
||||
/// 构建 URL 预览
|
||||
///
|
||||
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
|
||||
/// 这个命令与后端代理的 URL 构建逻辑保持一致。
|
||||
#[tauri::command]
|
||||
pub fn build_url_preview(
|
||||
app_type: String,
|
||||
base_url: String,
|
||||
api_format: Option<String>,
|
||||
) -> Result<UrlPreview, String> {
|
||||
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
|
||||
Ok(url_builder::build_url_preview(
|
||||
&app,
|
||||
&base_url,
|
||||
api_format.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 检查是否需要代理
|
||||
///
|
||||
/// 返回需要代理的原因(openai_chat_format, full_url, url_mismatch),
|
||||
/// 或 null 表示不需要代理。
|
||||
#[tauri::command]
|
||||
pub fn check_proxy_requirement(
|
||||
app_type: String,
|
||||
base_url: String,
|
||||
api_format: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
|
||||
Ok(
|
||||
url_builder::check_proxy_requirement(&app, &base_url, api_format.as_deref())
|
||||
.map(|s| s.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -911,6 +911,9 @@ pub fn run() {
|
||||
commands::get_circuit_breaker_config,
|
||||
commands::update_circuit_breaker_config,
|
||||
commands::get_circuit_breaker_stats,
|
||||
// URL preview (for endpoint field)
|
||||
commands::build_url_preview,
|
||||
commands::check_proxy_requirement,
|
||||
// Failover queue management
|
||||
commands::get_failover_queue,
|
||||
commands::get_available_providers_for_failover,
|
||||
|
||||
@@ -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 格式不是全链接
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -933,7 +933,10 @@ function App() {
|
||||
<>
|
||||
{activeApp !== "opencode" && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<ProxyToggle
|
||||
activeApp={activeApp}
|
||||
currentProvider={providers[currentProviderId] || null}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
|
||||
@@ -1,50 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Zap, AlertTriangle, Server, Unplug } from "lucide-react";
|
||||
import type { ClaudeApiFormat } from "@/types";
|
||||
|
||||
// 检测 URL 是否以 API 路径结尾的模式(只检测结尾,不包含 /v1 因为它是正常的 base URL 后缀)
|
||||
const API_PATH_SUFFIX_PATTERNS = [
|
||||
// Claude 完整路径
|
||||
"/v1/messages",
|
||||
"/messages",
|
||||
// OpenAI Chat Completions 完整路径
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
// Codex Responses 完整路径
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
// Gemini 完整路径
|
||||
"/v1beta/models",
|
||||
];
|
||||
|
||||
// 从 URL 中提取路径部分(移除查询参数和尾部斜杠)
|
||||
function extractUrlPath(url: string): string {
|
||||
try {
|
||||
// 移除查询参数
|
||||
const pathPart = url.split("?")[0];
|
||||
// 移除尾部斜杠并转为小写
|
||||
return pathPart.replace(/\/+$/, "").toLowerCase();
|
||||
} catch {
|
||||
return url.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 URL 并去重 /v1/v1
|
||||
function buildUrl(base: string, suffix: string): string {
|
||||
const trimmedBase = base.trim().replace(/\/+$/, "");
|
||||
let url = `${trimmedBase}${suffix}`;
|
||||
// 去重 /v1/v1 模式
|
||||
while (url.includes("/v1/v1")) {
|
||||
url = url.replace("/v1/v1", "/v1");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
import { proxyApi, type UrlPreview } from "@/lib/api/proxy";
|
||||
|
||||
type AppType = "claude" | "codex" | "gemini";
|
||||
type CodexApiFormat = "responses" | "chat";
|
||||
|
||||
interface EndpointFieldProps {
|
||||
id: string;
|
||||
@@ -56,9 +17,9 @@ interface EndpointFieldProps {
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
// 新增:应用类型和 API 格式
|
||||
// 应用类型和 API 格式
|
||||
appType?: AppType;
|
||||
apiFormat?: ClaudeApiFormat | CodexApiFormat;
|
||||
apiFormat?: string;
|
||||
// 是否显示请求地址预览
|
||||
showUrlPreview?: boolean;
|
||||
}
|
||||
@@ -78,65 +39,36 @@ export function EndpointField({
|
||||
showUrlPreview = true,
|
||||
}: EndpointFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const [urlPreview, setUrlPreview] = useState<UrlPreview | null>(null);
|
||||
|
||||
const defaultManageLabel = t("providerForm.manageAndTest", {
|
||||
defaultValue: "管理和测速",
|
||||
});
|
||||
|
||||
// 根据 appType 和 apiFormat 计算直连和代理后缀
|
||||
const suffixes = useMemo(() => {
|
||||
if (!appType) return null;
|
||||
|
||||
if (appType === "claude") {
|
||||
// Claude: 直连固定 /v1/messages,代理根据 apiFormat 决定
|
||||
return {
|
||||
direct: "/v1/messages",
|
||||
proxy:
|
||||
apiFormat === "openai_chat" ? "/v1/chat/completions" : "/v1/messages",
|
||||
};
|
||||
// 调用后端 API 获取 URL 预览
|
||||
useEffect(() => {
|
||||
if (!value || !appType || !showUrlPreview) {
|
||||
setUrlPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (appType === "codex") {
|
||||
// Codex: 直连固定 /responses(base URL 已含 /v1),代理根据 apiFormat 决定
|
||||
return {
|
||||
direct: "/responses",
|
||||
proxy: apiFormat === "chat" ? "/chat/completions" : "/responses",
|
||||
};
|
||||
}
|
||||
// 防抖:延迟 300ms 后请求
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const preview = await proxyApi.buildUrlPreview(
|
||||
appType,
|
||||
value,
|
||||
apiFormat,
|
||||
);
|
||||
setUrlPreview(preview);
|
||||
} catch (error) {
|
||||
console.error("Failed to build URL preview:", error);
|
||||
setUrlPreview(null);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
if (appType === "gemini") {
|
||||
// Gemini: 两种模式相同
|
||||
return {
|
||||
direct: "/v1beta/models",
|
||||
proxy: "/v1beta/models",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [appType, apiFormat]);
|
||||
|
||||
// 检测 URL 是否已以 API 路径结尾(全链接)
|
||||
const urlEndsWithApiPath = useMemo(() => {
|
||||
if (!value) return false;
|
||||
const urlPath = extractUrlPath(value);
|
||||
return API_PATH_SUFFIX_PATTERNS.some((pattern) =>
|
||||
urlPath.endsWith(pattern.toLowerCase()),
|
||||
);
|
||||
}, [value]);
|
||||
|
||||
// 构建直连模式请求 URL
|
||||
const directUrlPreview = useMemo(() => {
|
||||
if (!value || !suffixes) return null;
|
||||
if (urlEndsWithApiPath) return value;
|
||||
return buildUrl(value, suffixes.direct);
|
||||
}, [value, suffixes, urlEndsWithApiPath]);
|
||||
|
||||
// 构建代理模式请求 URL
|
||||
const proxyUrlPreview = useMemo(() => {
|
||||
if (!value || !suffixes) return null;
|
||||
if (urlEndsWithApiPath) return value;
|
||||
return buildUrl(value, suffixes.proxy);
|
||||
}, [value, suffixes, urlEndsWithApiPath]);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, appType, apiFormat, showUrlPreview]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -163,51 +95,48 @@ export function EndpointField({
|
||||
/>
|
||||
|
||||
{/* 请求地址预览 */}
|
||||
{showUrlPreview && directUrlPreview && (
|
||||
{showUrlPreview && urlPreview && (
|
||||
<div className="p-2 bg-muted/50 border border-border rounded-md space-y-2">
|
||||
{/* 直连模式请求地址 */}
|
||||
{/* CLI 直连请求地址 */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||
<Unplug className="h-3 w-3" />
|
||||
{t("providerForm.directRequestUrl", {
|
||||
defaultValue: "直连请求地址:",
|
||||
defaultValue: "CLI 直连请求地址:",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-foreground break-all pl-4">
|
||||
{directUrlPreview}
|
||||
{urlPreview.direct_url}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
|
||||
{t("providerForm.directRequestUrlDesc", {
|
||||
defaultValue: "不开启代理时,客户端直接请求此地址",
|
||||
defaultValue: "CLI 硬拼接默认后缀后的实际请求地址",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 代理模式请求地址 */}
|
||||
{proxyUrlPreview && (
|
||||
<div className="pt-1.5 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
{t("providerForm.proxyRequestUrl", {
|
||||
defaultValue: "代理请求地址:",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-foreground break-all pl-4">
|
||||
{proxyUrlPreview}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
|
||||
{t("providerForm.proxyRequestUrlDesc", {
|
||||
defaultValue:
|
||||
"开启代理后,代理服务会将请求转发到此地址(支持格式转换)",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* CCS 代理请求地址 */}
|
||||
<div className="pt-1.5 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
{t("providerForm.proxyRequestUrl", {
|
||||
defaultValue: "CCS 代理请求地址:",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-foreground break-all pl-4">
|
||||
{urlPreview.proxy_url}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
|
||||
{t("providerForm.proxyRequestUrlDesc", {
|
||||
defaultValue: "CCS 智能拼接后转发到上游的地址",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全链接警告 */}
|
||||
{urlEndsWithApiPath && (
|
||||
{urlPreview?.is_full_url && (
|
||||
<div className="flex items-start gap-2 p-2 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-md">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -10,19 +10,99 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
import type { Provider } from "@/types";
|
||||
|
||||
interface ProxyToggleProps {
|
||||
className?: string;
|
||||
activeApp: AppId;
|
||||
currentProvider?: Provider | null;
|
||||
}
|
||||
|
||||
export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
/**
|
||||
* 从 provider 配置中提取 base URL
|
||||
*/
|
||||
function extractBaseUrl(provider: Provider, appId: AppId): string | null {
|
||||
try {
|
||||
const config = provider.settingsConfig;
|
||||
if (!config) return null;
|
||||
|
||||
if (appId === "claude") {
|
||||
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
return typeof envUrl === "string" ? envUrl.trim() : null;
|
||||
}
|
||||
|
||||
if (appId === "codex") {
|
||||
const tomlConfig = config?.config;
|
||||
if (typeof tomlConfig === "string") {
|
||||
const match = tomlConfig.match(/base_url\s*=\s*(['"])([^'"]+)\1/);
|
||||
return match?.[2]?.trim() || null;
|
||||
}
|
||||
const baseUrl = config?.base_url;
|
||||
return typeof baseUrl === "string" ? baseUrl.trim() : null;
|
||||
}
|
||||
|
||||
if (appId === "gemini") {
|
||||
const envUrl = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||
if (typeof envUrl === "string") return envUrl.trim();
|
||||
const baseUrl = config?.GEMINI_API_BASE || config?.base_url;
|
||||
return typeof baseUrl === "string" ? baseUrl.trim() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProxyToggle({
|
||||
className,
|
||||
activeApp,
|
||||
currentProvider,
|
||||
}: ProxyToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
|
||||
useProxyStatus();
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
// 关闭代理时,检查当前供应商是否是全链接配置
|
||||
if (
|
||||
!checked &&
|
||||
currentProvider &&
|
||||
currentProvider.category !== "official"
|
||||
) {
|
||||
const baseUrl = extractBaseUrl(currentProvider, activeApp);
|
||||
const apiFormat = currentProvider.meta?.apiFormat;
|
||||
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const proxyRequirement = await proxyApi.checkProxyRequirement(
|
||||
activeApp,
|
||||
baseUrl,
|
||||
apiFormat,
|
||||
);
|
||||
|
||||
if (proxyRequirement) {
|
||||
// 显示警告但仍允许关闭
|
||||
toast.warning(
|
||||
t("notifications.fullUrlWarningOnDisable", {
|
||||
defaultValue:
|
||||
"当前供应商配置了完整 API 路径或特殊格式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
|
||||
}),
|
||||
{
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check proxy requirement:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await setTakeoverForApp({ appType: activeApp, enabled: checked });
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
import type { Provider, UsageScript } from "@/types";
|
||||
import {
|
||||
useAddProviderMutation,
|
||||
@@ -12,17 +13,6 @@ import {
|
||||
} from "@/lib/query";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
// API 路径后缀模式,用于检测是否是全链接
|
||||
const API_PATH_SUFFIX_PATTERNS = [
|
||||
"/v1/messages",
|
||||
"/messages",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
"/v1beta/models",
|
||||
];
|
||||
|
||||
/**
|
||||
* 从 Codex TOML 配置字符串中提取 base_url
|
||||
*/
|
||||
@@ -72,20 +62,6 @@ function extractBaseUrl(provider: Provider, appId: AppId): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 URL 是否以 API 路径后缀结尾(全链接)
|
||||
*/
|
||||
function isFullApiUrl(url: string | null): boolean {
|
||||
if (!url) return false;
|
||||
|
||||
// 移除查询参数和尾部斜杠
|
||||
const pathPart = url.split("?")[0].replace(/\/+$/, "").toLowerCase();
|
||||
|
||||
return API_PATH_SUFFIX_PATTERNS.some((pattern) =>
|
||||
pathPart.endsWith(pattern.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
interface UseProviderActionsOptions {
|
||||
/** 代理服务是否正在运行 */
|
||||
isProxyRunning?: boolean;
|
||||
@@ -166,28 +142,63 @@ export function useProviderActions(
|
||||
// 切换供应商
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
// 检测是否是全链接配置(URL 以 API 路径结尾)
|
||||
const baseUrl = extractBaseUrl(provider, activeApp);
|
||||
const hasFullApiUrl = isFullApiUrl(baseUrl);
|
||||
// 官方供应商不需要检查
|
||||
if (provider.category === "official") {
|
||||
try {
|
||||
await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
toast.success(
|
||||
t("notifications.switchSuccess", { defaultValue: "切换成功!" }),
|
||||
{
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// 错误提示由 mutation 处理
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测是否是 Claude OpenAI Chat 格式(需要代理进行格式转换)
|
||||
const isClaudeOpenAIChatFormat =
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
provider.meta?.apiFormat === "openai_chat";
|
||||
// 提取 base URL 和 API 格式
|
||||
const baseUrl = extractBaseUrl(provider, activeApp);
|
||||
const apiFormat = provider.meta?.apiFormat;
|
||||
|
||||
// 调用后端 API 检查是否需要代理(前后端使用相同逻辑)
|
||||
let proxyRequirement: string | null = null;
|
||||
if (baseUrl) {
|
||||
try {
|
||||
proxyRequirement = await proxyApi.checkProxyRequirement(
|
||||
activeApp,
|
||||
baseUrl,
|
||||
apiFormat,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to check proxy requirement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果需要代理但代理未激活,阻止切换并提示
|
||||
const needsProxy = hasFullApiUrl || isClaudeOpenAIChatFormat;
|
||||
if (needsProxy && !(isProxyRunning && isTakeoverActive)) {
|
||||
const message = isClaudeOpenAIChatFormat
|
||||
? t("notifications.openAIChatFormatRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
|
||||
})
|
||||
: t("notifications.fullUrlRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商配置了完整 API 路径,需要开启代理服务才能正常使用。请先开启代理并接管当前应用。",
|
||||
});
|
||||
if (proxyRequirement && !(isProxyRunning && isTakeoverActive)) {
|
||||
let message: string;
|
||||
|
||||
if (proxyRequirement === "openai_chat_format") {
|
||||
message = t("notifications.openAIChatFormatRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
|
||||
});
|
||||
} else if (proxyRequirement === "full_url") {
|
||||
// 用户填了全链接(如 /v1/messages 结尾)
|
||||
message = t("notifications.fullUrlRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。",
|
||||
});
|
||||
} else {
|
||||
// url_mismatch: 直连地址和代理地址不匹配
|
||||
message = t("notifications.urlMismatchRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。",
|
||||
});
|
||||
}
|
||||
|
||||
toast.warning(message, {
|
||||
duration: 6000,
|
||||
|
||||
@@ -158,7 +158,9 @@
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
|
||||
"openAIChatFormatRequiresProxy": "This provider uses OpenAI Chat format and requires the proxy service for format conversion. Please enable the proxy and takeover the current app first.",
|
||||
"fullUrlRequiresProxy": "This provider is configured with a full API path and requires the proxy service to work properly. Please enable the proxy and takeover the current app first."
|
||||
"fullUrlRequiresProxy": "This provider is configured with a full API path. The client may append duplicate paths in direct connection mode. Please enable the proxy and takeover the current app first.",
|
||||
"urlMismatchRequiresProxy": "This provider's request URL configuration does not match the API format. It cannot work properly in direct connection mode. Please enable the proxy and takeover the current app first.",
|
||||
"fullUrlWarningOnDisable": "The current provider is configured with a full API path or special format. It may not work properly after disabling the proxy. Consider switching to a base URL configuration or keep the proxy enabled."
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -495,10 +497,10 @@
|
||||
"codexApiFormatResponses": "OpenAI Responses (Default)",
|
||||
"codexApiFormatChat": "OpenAI Chat Completions",
|
||||
"codexApiFormatHint": "Select the API format supported by the provider",
|
||||
"directRequestUrl": "Direct request URL:",
|
||||
"directRequestUrlDesc": "When proxy is disabled, the client requests this URL directly",
|
||||
"proxyRequestUrl": "Proxy request URL:",
|
||||
"proxyRequestUrlDesc": "When proxy is enabled, requests are forwarded to this URL (with format conversion)",
|
||||
"directRequestUrl": "CLI Direct Request URL:",
|
||||
"directRequestUrlDesc": "Actual request URL after CLI appends default suffix",
|
||||
"proxyRequestUrl": "CCS Proxy Request URL:",
|
||||
"proxyRequestUrlDesc": "URL forwarded to upstream after CCS smart path detection",
|
||||
"fullUrlWarningTitle": "Full API path detected",
|
||||
"fullUrlWarning": "The URL contains a full API path. This configuration only works in proxy mode. For direct connection mode, please enter only the base URL.",
|
||||
"fillSupplierName": "Please fill in provider name",
|
||||
|
||||
@@ -158,7 +158,9 @@
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"openAIChatFormatRequiresProxy": "このプロバイダーは OpenAI Chat フォーマットを使用しており、フォーマット変換のためにプロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
|
||||
"fullUrlRequiresProxy": "このプロバイダーは完全な API パスで構成されており、プロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。"
|
||||
"fullUrlRequiresProxy": "このプロバイダーは完全な API パスで構成されています。直接接続モードではクライアントがパスを重複追加する可能性があります。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
|
||||
"urlMismatchRequiresProxy": "このプロバイダーのリクエスト URL 設定が API フォーマットと一致しません。直接接続モードでは正常に動作しません。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
|
||||
"fullUrlWarningOnDisable": "現在のプロバイダーは完全な API パスまたは特殊なフォーマットで構成されています。プロキシを無効にすると正常に動作しない可能性があります。ベース URL 設定に変更するか、プロキシを有効のままにすることをお勧めします。"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -495,10 +497,10 @@
|
||||
"codexApiFormatResponses": "OpenAI Responses(デフォルト)",
|
||||
"codexApiFormatChat": "OpenAI Chat Completions",
|
||||
"codexApiFormatHint": "プロバイダーがサポートする API フォーマットを選択",
|
||||
"directRequestUrl": "直接接続リクエスト URL:",
|
||||
"directRequestUrlDesc": "プロキシ無効時、クライアントはこの URL に直接リクエストします",
|
||||
"proxyRequestUrl": "プロキシリクエスト URL:",
|
||||
"proxyRequestUrlDesc": "プロキシ有効時、リクエストはこの URL に転送されます(フォーマット変換対応)",
|
||||
"directRequestUrl": "CLI 直接リクエスト URL:",
|
||||
"directRequestUrlDesc": "CLI がデフォルトサフィックスを追加した後の実際のリクエスト URL",
|
||||
"proxyRequestUrl": "CCS プロキシリクエスト URL:",
|
||||
"proxyRequestUrlDesc": "CCS がスマートパス検出後にアップストリームへ転送する URL",
|
||||
"fullUrlWarningTitle": "完全な API パスを検出",
|
||||
"fullUrlWarning": "URL には完全な API パスが含まれています。この設定はプロキシモードでのみ有効です。直接接続モードではベース URL のみを入力してください。",
|
||||
"fillSupplierName": "プロバイダー名を入力してください",
|
||||
|
||||
@@ -158,7 +158,9 @@
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
"openAIChatFormatRequiresProxy": "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
|
||||
"fullUrlRequiresProxy": "此供应商配置了完整 API 路径,需要开启代理服务才能正常使用。请先开启代理并接管当前应用。"
|
||||
"fullUrlRequiresProxy": "此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。",
|
||||
"urlMismatchRequiresProxy": "此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。",
|
||||
"fullUrlWarningOnDisable": "当前供应商配置了完整 API 路径或特殊格式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -495,10 +497,10 @@
|
||||
"codexApiFormatResponses": "OpenAI Responses (默认)",
|
||||
"codexApiFormatChat": "OpenAI Chat Completions",
|
||||
"codexApiFormatHint": "选择供应商支持的 API 格式",
|
||||
"directRequestUrl": "直连请求地址:",
|
||||
"directRequestUrlDesc": "不开启代理时,客户端直接请求此地址",
|
||||
"proxyRequestUrl": "代理请求地址:",
|
||||
"proxyRequestUrlDesc": "开启代理后,代理服务会将请求转发到此地址(支持格式转换)",
|
||||
"directRequestUrl": "CLI 直连请求地址:",
|
||||
"directRequestUrlDesc": "CLI 硬拼接默认后缀后的实际请求地址",
|
||||
"proxyRequestUrl": "CCS 代理请求地址:",
|
||||
"proxyRequestUrlDesc": "CCS 智能拼接后转发到上游的地址",
|
||||
"fullUrlWarningTitle": "检测到完整 API 路径",
|
||||
"fullUrlWarning": "填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。",
|
||||
"fillSupplierName": "请填写供应商名称",
|
||||
|
||||
@@ -117,4 +117,40 @@ export const proxyApi = {
|
||||
async setPricingModelSource(appType: string, value: string): Promise<void> {
|
||||
return invoke("set_pricing_model_source", { appType, value });
|
||||
},
|
||||
|
||||
// ========== URL 预览 API ==========
|
||||
|
||||
/**
|
||||
* 构建 URL 预览
|
||||
* 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址
|
||||
*/
|
||||
async buildUrlPreview(
|
||||
appType: string,
|
||||
baseUrl: string,
|
||||
apiFormat?: string,
|
||||
): Promise<UrlPreview> {
|
||||
return invoke("build_url_preview", { appType, baseUrl, apiFormat });
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否需要代理
|
||||
* 返回需要代理的原因,null 表示不需要代理
|
||||
*/
|
||||
async checkProxyRequirement(
|
||||
appType: string,
|
||||
baseUrl: string,
|
||||
apiFormat?: string,
|
||||
): Promise<string | null> {
|
||||
return invoke("check_proxy_requirement", { appType, baseUrl, apiFormat });
|
||||
},
|
||||
};
|
||||
|
||||
/** URL 预览结果 */
|
||||
export interface UrlPreview {
|
||||
/** 直连模式请求地址 */
|
||||
direct_url: string;
|
||||
/** 代理模式请求地址 */
|
||||
proxy_url: string;
|
||||
/** 是否为全链接(base_url 已包含 API 路径) */
|
||||
is_full_url: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user