mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 18:05:37 +08:00
feat(proxy): add smart URL path detection for full API endpoints
- Detect when base URL already contains API path suffix (/v1/messages, /v1/chat/completions, etc.) - Show direct and proxy request URL previews in endpoint field - Block provider switch when proxy is required but not active - Add API format selector for Codex providers (Responses/Chat)
This commit is contained in:
@@ -252,11 +252,28 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
endpoint.trim_start_matches('/')
|
||||
);
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
|
||||
// 支持的 API 路径模式:/v1/messages, /messages, /v1/chat/completions, /chat/completions
|
||||
let api_path_patterns = [
|
||||
"/v1/messages",
|
||||
"/messages",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
];
|
||||
|
||||
let base_ends_with_api_path = api_path_patterns
|
||||
.iter()
|
||||
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
|
||||
|
||||
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
|
||||
let mut base = if base_ends_with_api_path {
|
||||
base_trimmed.to_string()
|
||||
} else {
|
||||
format!("{base_trimmed}/{endpoint_trimmed}")
|
||||
};
|
||||
|
||||
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||||
while base.contains("/v1/v1") {
|
||||
@@ -266,9 +283,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 为 Claude 相关端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
|
||||
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
// 检查最终 URL 是否包含需要 beta 参数的路径,且没有查询参数
|
||||
let needs_beta = (base.contains("/v1/messages") || base.contains("/v1/chat/completions"))
|
||||
&& !base.contains('?');
|
||||
|
||||
if needs_beta {
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
@@ -513,6 +532,59 @@ mod tests {
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_full_path_messages() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// base_url 已包含完整路径 /v1/messages,不再追加
|
||||
let url = adapter.build_url("https://example.com/api/v1/messages", "/v1/messages");
|
||||
assert_eq!(url, "https://example.com/api/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_full_path_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// base_url 已包含完整路径 /v1/chat/completions,不再追加
|
||||
let url = adapter.build_url(
|
||||
"https://opencode.ai/zen/v1/chat/completions",
|
||||
"/v1/chat/completions",
|
||||
);
|
||||
assert_eq!(url, "https://opencode.ai/zen/v1/chat/completions?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_full_path_short_suffix() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// base_url 以 /messages 结尾(无 /v1 前缀)
|
||||
let url = adapter.build_url("https://example.com/api/messages", "/v1/messages");
|
||||
assert_eq!(url, "https://example.com/api/messages");
|
||||
|
||||
// base_url 以 /chat/completions 结尾(无 /v1 前缀)
|
||||
let url2 = adapter.build_url(
|
||||
"https://example.com/api/chat/completions",
|
||||
"/v1/chat/completions",
|
||||
);
|
||||
assert_eq!(url2, "https://example.com/api/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_v1_base_dedup() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// base_url 以 /v1 结尾,endpoint 也以 /v1 开头,应该去重
|
||||
// 场景:https://integrate.api.nvidia.com/v1 + /v1/chat/completions
|
||||
let url = adapter.build_url(
|
||||
"https://integrate.api.nvidia.com/v1",
|
||||
"/v1/chat/completions",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://integrate.api.nvidia.com/v1/chat/completions?beta=true"
|
||||
);
|
||||
|
||||
// 另一个场景:/v1 + /v1/messages
|
||||
let url2 = adapter.build_url("https://api.example.com/v1", "/v1/messages");
|
||||
assert_eq!(url2, "https://api.example.com/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
@@ -141,6 +141,24 @@ impl ProviderAdapter for CodexAdapter {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
|
||||
// 支持的 API 路径模式:/v1/responses, /responses, /v1/chat/completions, /chat/completions
|
||||
let api_path_patterns = [
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
];
|
||||
|
||||
let base_ends_with_api_path = api_path_patterns
|
||||
.iter()
|
||||
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
|
||||
|
||||
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
|
||||
if base_ends_with_api_path {
|
||||
return base_trimmed.to_string();
|
||||
}
|
||||
|
||||
// OpenAI/Codex 的 base_url 可能是:
|
||||
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
|
||||
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
|
||||
@@ -268,6 +286,49 @@ mod tests {
|
||||
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_full_path_responses() {
|
||||
let adapter = CodexAdapter::new();
|
||||
// base_url 已包含完整路径 /v1/responses,不再追加
|
||||
let url = adapter.build_url("https://example.com/v1/responses", "/responses");
|
||||
assert_eq!(url, "https://example.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_full_path_chat_completions() {
|
||||
let adapter = CodexAdapter::new();
|
||||
// base_url 已包含完整路径 /v1/chat/completions,不再追加
|
||||
let url = adapter.build_url(
|
||||
"https://example.com/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
);
|
||||
assert_eq!(url, "https://example.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_full_path_short_suffix() {
|
||||
let adapter = CodexAdapter::new();
|
||||
// base_url 以 /responses 结尾(无 /v1 前缀)
|
||||
let url = adapter.build_url("https://example.com/api/responses", "/responses");
|
||||
assert_eq!(url, "https://example.com/api/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_v1_base_dedup() {
|
||||
let adapter = CodexAdapter::new();
|
||||
// base_url 以 /v1 结尾,endpoint 也以 /v1 开头,应该去重
|
||||
// 场景:https://integrate.api.nvidia.com/v1 + /v1/responses
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com/v1", "/v1/responses");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/responses");
|
||||
|
||||
// 另一个场景:/v1 + /v1/chat/completions
|
||||
let url2 = adapter.build_url(
|
||||
"https://integrate.api.nvidia.com/v1",
|
||||
"/v1/chat/completions",
|
||||
);
|
||||
assert_eq!(url2, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
// 官方客户端检测测试
|
||||
#[test]
|
||||
fn test_is_official_client_vscode() {
|
||||
|
||||
+4
-1
@@ -195,7 +195,10 @@ function App() {
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
} = useProviderActions(activeApp);
|
||||
} = useProviderActions(activeApp, {
|
||||
isProxyRunning,
|
||||
isTakeoverActive: isCurrentAppTakeoverActive,
|
||||
});
|
||||
|
||||
// 监听来自托盘菜单的切换事件
|
||||
useEffect(() => {
|
||||
|
||||
@@ -169,6 +169,8 @@ export function ClaudeFormFields({
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
appType="claude"
|
||||
apiFormat={apiFormat}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,11 +2,22 @@ import { useTranslation } from "react-i18next";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
|
||||
interface EndpointCandidate {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Codex API 格式类型
|
||||
export type CodexApiFormat = "responses" | "chat";
|
||||
|
||||
interface CodexFormFieldsProps {
|
||||
providerId?: string;
|
||||
// API Key
|
||||
@@ -35,6 +46,11 @@ interface CodexFormFieldsProps {
|
||||
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
|
||||
// API Format (for Codex providers)
|
||||
apiFormat?: CodexApiFormat;
|
||||
onApiFormatChange?: (format: CodexApiFormat) => void;
|
||||
shouldShowApiFormatSelector?: boolean;
|
||||
}
|
||||
|
||||
export function CodexFormFields({
|
||||
@@ -58,9 +74,21 @@ export function CodexFormFields({
|
||||
modelName = "",
|
||||
onModelNameChange,
|
||||
speedTestEndpoints,
|
||||
apiFormat = "responses",
|
||||
onApiFormatChange,
|
||||
shouldShowApiFormatSelector = false,
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 根据 API 格式选择提示文本
|
||||
const apiHint =
|
||||
apiFormat === "chat"
|
||||
? t("providerForm.codexApiHintChat", {
|
||||
defaultValue:
|
||||
"💡 填写兼容 OpenAI Chat Completions 格式的服务端点地址",
|
||||
})
|
||||
: t("providerForm.codexApiHint");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Codex API Key 输入框 */}
|
||||
@@ -84,6 +112,37 @@ export function CodexFormFields({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* API 格式选择器 */}
|
||||
{shouldShowApiFormatSelector && onApiFormatChange && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="codexApiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="codexApiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="responses">
|
||||
{t("providerForm.codexApiFormatResponses", {
|
||||
defaultValue: "OpenAI Responses (默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="chat">
|
||||
{t("providerForm.codexApiFormatChat", {
|
||||
defaultValue: "OpenAI Chat Completions",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.codexApiFormatHint", {
|
||||
defaultValue: "选择供应商支持的 API 格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Base URL 输入框 */}
|
||||
{shouldShowSpeedTest && (
|
||||
<EndpointField
|
||||
@@ -92,8 +151,10 @@ export function CodexFormFields({
|
||||
value={codexBaseUrl}
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
|
||||
hint={t("providerForm.codexApiHint")}
|
||||
hint={apiHint}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
appType="codex"
|
||||
apiFormat={apiFormat}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Zap } from "lucide-react";
|
||||
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;
|
||||
}
|
||||
|
||||
type AppType = "claude" | "codex" | "gemini";
|
||||
type CodexApiFormat = "responses" | "chat";
|
||||
|
||||
interface EndpointFieldProps {
|
||||
id: string;
|
||||
@@ -13,6 +56,11 @@ interface EndpointFieldProps {
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
// 新增:应用类型和 API 格式
|
||||
appType?: AppType;
|
||||
apiFormat?: ClaudeApiFormat | CodexApiFormat;
|
||||
// 是否显示请求地址预览
|
||||
showUrlPreview?: boolean;
|
||||
}
|
||||
|
||||
export function EndpointField({
|
||||
@@ -25,6 +73,9 @@ export function EndpointField({
|
||||
showManageButton = true,
|
||||
onManageClick,
|
||||
manageButtonLabel,
|
||||
appType,
|
||||
apiFormat,
|
||||
showUrlPreview = true,
|
||||
}: EndpointFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -32,6 +83,61 @@ export function EndpointField({
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
if (appType === "codex") {
|
||||
// Codex: 直连固定 /responses(base URL 已含 /v1),代理根据 apiFormat 决定
|
||||
return {
|
||||
direct: "/responses",
|
||||
proxy: apiFormat === "chat" ? "/chat/completions" : "/responses",
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -55,6 +161,71 @@ export function EndpointField({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* 请求地址预览 */}
|
||||
{showUrlPreview && directUrlPreview && (
|
||||
<div className="p-2 bg-muted/50 border border-border rounded-md space-y-2">
|
||||
{/* 直连模式请求地址 */}
|
||||
<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: "直连请求地址:",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-foreground break-all pl-4">
|
||||
{directUrlPreview}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
|
||||
{t("providerForm.directRequestUrlDesc", {
|
||||
defaultValue: "不开启代理时,客户端直接请求此地址",
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全链接警告 */}
|
||||
{urlEndsWithApiPath && (
|
||||
<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">
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 font-medium">
|
||||
{t("providerForm.fullUrlWarningTitle", {
|
||||
defaultValue: "检测到完整 API 路径",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-orange-600/80 dark:text-orange-400/80 mt-0.5">
|
||||
{t("providerForm.fullUrlWarning", {
|
||||
defaultValue:
|
||||
"填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hint ? (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
|
||||
+135
-32
@@ -12,11 +12,96 @@ 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
|
||||
*/
|
||||
function extractCodexBaseUrlFromToml(tomlConfig: string | null): string | null {
|
||||
if (!tomlConfig || typeof tomlConfig !== "string") return null;
|
||||
// 匹配 base_url = "xxx" 或 base_url = 'xxx'
|
||||
const match = tomlConfig.match(/base_url\s*=\s*(['"])([^'"]+)\1/);
|
||||
return match?.[2]?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 provider 配置中提取 base URL
|
||||
*/
|
||||
function extractBaseUrl(provider: Provider, appId: AppId): string | null {
|
||||
try {
|
||||
const config = provider.settingsConfig;
|
||||
if (!config) return null;
|
||||
|
||||
if (appId === "claude") {
|
||||
// Claude: env.ANTHROPIC_BASE_URL
|
||||
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
return typeof envUrl === "string" ? envUrl.trim() : null;
|
||||
}
|
||||
|
||||
if (appId === "codex") {
|
||||
// Codex: base_url 存储在 config 字段中(TOML 字符串)
|
||||
const tomlConfig = config?.config;
|
||||
if (typeof tomlConfig === "string") {
|
||||
return extractCodexBaseUrlFromToml(tomlConfig);
|
||||
}
|
||||
// 回退:尝试直接获取 base_url 字段
|
||||
const baseUrl = config?.base_url;
|
||||
return typeof baseUrl === "string" ? baseUrl.trim() : null;
|
||||
}
|
||||
|
||||
if (appId === "gemini") {
|
||||
// Gemini: env.GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_BASE
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 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;
|
||||
/** 当前应用的代理接管是否激活 */
|
||||
isTakeoverActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
* Extracts business logic from App.tsx
|
||||
*/
|
||||
export function useProviderActions(activeApp: AppId) {
|
||||
export function useProviderActions(
|
||||
activeApp: AppId,
|
||||
options: UseProviderActionsOptions = {},
|
||||
) {
|
||||
const { isProxyRunning = false, isTakeoverActive = false } = options;
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -81,46 +166,64 @@ export function useProviderActions(activeApp: AppId) {
|
||||
// 切换供应商
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
// 检测是否是全链接配置(URL 以 API 路径结尾)
|
||||
const baseUrl = extractBaseUrl(provider, activeApp);
|
||||
const hasFullApiUrl = isFullApiUrl(baseUrl);
|
||||
|
||||
// 检测是否是 Claude OpenAI Chat 格式(需要代理进行格式转换)
|
||||
const isClaudeOpenAIChatFormat =
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
provider.meta?.apiFormat === "openai_chat";
|
||||
|
||||
// 如果需要代理但代理未激活,阻止切换并提示
|
||||
const needsProxy = hasFullApiUrl || isClaudeOpenAIChatFormat;
|
||||
if (needsProxy && !(isProxyRunning && isTakeoverActive)) {
|
||||
const message = isClaudeOpenAIChatFormat
|
||||
? t("notifications.openAIChatFormatRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
|
||||
})
|
||||
: t("notifications.fullUrlRequiresProxy", {
|
||||
defaultValue:
|
||||
"此供应商配置了完整 API 路径,需要开启代理服务才能正常使用。请先开启代理并接管当前应用。",
|
||||
});
|
||||
|
||||
toast.warning(message, {
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
});
|
||||
return; // 阻止切换
|
||||
}
|
||||
|
||||
try {
|
||||
await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
|
||||
// 根据供应商类型显示不同的成功提示
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
provider.meta?.apiFormat === "openai_chat"
|
||||
) {
|
||||
// OpenAI Chat 格式供应商:显示代理提示
|
||||
toast.info(
|
||||
t("notifications.openAIChatFormatHint", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
}),
|
||||
{
|
||||
duration: 5000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 普通供应商:显示切换成功
|
||||
// OpenCode: show "added to config" message instead of "switched"
|
||||
const messageKey =
|
||||
activeApp === "opencode"
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage =
|
||||
activeApp === "opencode" ? "已添加到配置" : "切换成功!";
|
||||
// 普通供应商:显示切换成功
|
||||
// OpenCode: show "added to config" message instead of "switched"
|
||||
const messageKey =
|
||||
activeApp === "opencode"
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage =
|
||||
activeApp === "opencode" ? "已添加到配置" : "切换成功!";
|
||||
|
||||
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch {
|
||||
// 错误提示由 mutation 处理
|
||||
}
|
||||
},
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, t],
|
||||
[
|
||||
switchProviderMutation,
|
||||
syncClaudePlugin,
|
||||
activeApp,
|
||||
t,
|
||||
isProxyRunning,
|
||||
isTakeoverActive,
|
||||
],
|
||||
);
|
||||
|
||||
// 删除供应商
|
||||
|
||||
@@ -156,7 +156,9 @@
|
||||
"deleteFailed": "Failed to delete provider: {{error}}",
|
||||
"settingsSaved": "Settings saved",
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled"
|
||||
"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."
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -489,6 +491,16 @@
|
||||
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
|
||||
"apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash",
|
||||
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
|
||||
"codexApiHintChat": "💡 Fill in service endpoint compatible with OpenAI Chat Completions format",
|
||||
"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)",
|
||||
"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",
|
||||
"fillConfigContent": "Please fill in configuration content",
|
||||
"fillParameter": "Please fill in {{label}}",
|
||||
|
||||
@@ -156,7 +156,9 @@
|
||||
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です"
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"openAIChatFormatRequiresProxy": "このプロバイダーは OpenAI Chat フォーマットを使用しており、フォーマット変換のためにプロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
|
||||
"fullUrlRequiresProxy": "このプロバイダーは完全な API パスで構成されており、プロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -489,6 +491,16 @@
|
||||
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
|
||||
"codexApiHintChat": "💡 OpenAI Chat Completions 互換のサービスエンドポイントを入力してください",
|
||||
"codexApiFormatResponses": "OpenAI Responses(デフォルト)",
|
||||
"codexApiFormatChat": "OpenAI Chat Completions",
|
||||
"codexApiFormatHint": "プロバイダーがサポートする API フォーマットを選択",
|
||||
"directRequestUrl": "直接接続リクエスト URL:",
|
||||
"directRequestUrlDesc": "プロキシ無効時、クライアントはこの URL に直接リクエストします",
|
||||
"proxyRequestUrl": "プロキシリクエスト URL:",
|
||||
"proxyRequestUrlDesc": "プロキシ有効時、リクエストはこの URL に転送されます(フォーマット変換対応)",
|
||||
"fullUrlWarningTitle": "完全な API パスを検出",
|
||||
"fullUrlWarning": "URL には完全な API パスが含まれています。この設定はプロキシモードでのみ有効です。直接接続モードではベース URL のみを入力してください。",
|
||||
"fillSupplierName": "プロバイダー名を入力してください",
|
||||
"fillConfigContent": "設定内容を入力してください",
|
||||
"fillParameter": "{{label}} を入力してください",
|
||||
|
||||
@@ -156,7 +156,9 @@
|
||||
"deleteFailed": "删除供应商失败:{{error}}",
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用"
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
"openAIChatFormatRequiresProxy": "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
|
||||
"fullUrlRequiresProxy": "此供应商配置了完整 API 路径,需要开启代理服务才能正常使用。请先开启代理并接管当前应用。"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -489,6 +491,16 @@
|
||||
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
|
||||
"apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾",
|
||||
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
|
||||
"codexApiHintChat": "💡 填写兼容 OpenAI Chat Completions 格式的服务端点地址",
|
||||
"codexApiFormatResponses": "OpenAI Responses (默认)",
|
||||
"codexApiFormatChat": "OpenAI Chat Completions",
|
||||
"codexApiFormatHint": "选择供应商支持的 API 格式",
|
||||
"directRequestUrl": "直连请求地址:",
|
||||
"directRequestUrlDesc": "不开启代理时,客户端直接请求此地址",
|
||||
"proxyRequestUrl": "代理请求地址:",
|
||||
"proxyRequestUrlDesc": "开启代理后,代理服务会将请求转发到此地址(支持格式转换)",
|
||||
"fullUrlWarningTitle": "检测到完整 API 路径",
|
||||
"fullUrlWarning": "填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。",
|
||||
"fillSupplierName": "请填写供应商名称",
|
||||
"fillConfigContent": "请填写配置内容",
|
||||
"fillParameter": "请填写 {{label}}",
|
||||
|
||||
Reference in New Issue
Block a user