mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
fix(proxy): unify preview/runtime URL rules and proxy checks
- align Codex preview URL building with runtime /v1 normalization - align Claude full-url detection with runtime adapter patterns - reflect Claude ?beta=true in proxy preview and avoid query-only mismatch false positives - enforce openai_chat proxy requirement even when baseUrl is missing - wire Codex api format selector through ProviderForm and persist meta/config - fix EndpointField stale async preview race on clear - add pending feedback for ProxyToggle pre-disable checks - deduplicate provider baseUrl extraction into a shared util
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
//! 提供统一的 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};
|
||||
|
||||
@@ -34,15 +35,25 @@ impl ApiPathPatterns {
|
||||
Self {
|
||||
direct_endpoint: "/v1/messages",
|
||||
proxy_endpoint: "/v1/chat/completions",
|
||||
// Chat 格式只检测 /chat/completions
|
||||
full_url_patterns: &["/v1/chat/completions", "/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",
|
||||
// 原生格式只检测 /messages
|
||||
full_url_patterns: &["/v1/messages", "/messages"],
|
||||
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
|
||||
full_url_patterns: &[
|
||||
"/v1/messages",
|
||||
"/messages",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +137,21 @@ pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str
|
||||
format!("{url}{suffix}")
|
||||
}
|
||||
|
||||
fn build_runtime_like_url(
|
||||
app_type: &AppType,
|
||||
base_url: &str,
|
||||
endpoint: &str,
|
||||
is_proxy: bool,
|
||||
) -> String {
|
||||
match app_type {
|
||||
// Claude 代理预览需要展示运行时附加参数(如 ?beta=true)
|
||||
AppType::Claude if is_proxy => ClaudeAdapter::new().build_url(base_url, endpoint),
|
||||
// Codex/OpenCode 预览复用运行时 /v1 归一化规则
|
||||
AppType::Codex | AppType::OpenCode => CodexAdapter::new().build_url(base_url, endpoint),
|
||||
_ => build_direct_url(base_url, endpoint),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 URL 预览
|
||||
///
|
||||
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
|
||||
@@ -145,14 +171,19 @@ pub fn build_url_preview(
|
||||
|
||||
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,
|
||||
);
|
||||
// 直连地址:默认硬拼接;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 => {
|
||||
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,
|
||||
@@ -180,7 +211,12 @@ pub fn check_proxy_requirement(
|
||||
if preview.is_full_url {
|
||||
// 检查是否以直连后缀结尾
|
||||
let direct_suffixes: &[&str] = match app_type {
|
||||
AppType::Claude => &["/v1/messages", "/messages"],
|
||||
AppType::Claude => &[
|
||||
"/v1/messages",
|
||||
"/messages",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
],
|
||||
AppType::Codex => &["/v1/responses", "/responses"],
|
||||
_ => return None,
|
||||
};
|
||||
@@ -190,8 +226,10 @@ pub fn check_proxy_requirement(
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直连地址和代理地址不同,需要代理
|
||||
if preview.direct_url != preview.proxy_url {
|
||||
// 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异,如 Claude ?beta=true)
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -210,7 +248,10 @@ mod tests {
|
||||
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_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/messages?beta=true"
|
||||
);
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
@@ -224,7 +265,7 @@ mod tests {
|
||||
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/chat/completions"
|
||||
"https://api.example.com/v1/chat/completions?beta=true"
|
||||
);
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
@@ -241,7 +282,10 @@ mod tests {
|
||||
preview.direct_url,
|
||||
"https://api.example.com/v1/messages/v1/messages"
|
||||
);
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/messages?beta=true"
|
||||
);
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
|
||||
@@ -257,10 +301,19 @@ mod tests {
|
||||
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_chat_proxy_endpoint() {
|
||||
let preview = build_url_preview(&AppType::Codex, "https://api.openai.com", Some("chat"));
|
||||
assert_eq!(preview.direct_url, "https://api.openai.com/responses");
|
||||
assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses");
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.openai.com/v1/chat/completions"
|
||||
@@ -270,16 +323,13 @@ mod tests {
|
||||
|
||||
#[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.direct_url, "https://api.example.com/v1/responses");
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/responses");
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
@@ -314,6 +364,13 @@ mod tests {
|
||||
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(
|
||||
@@ -340,8 +397,11 @@ mod tests {
|
||||
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");
|
||||
// 代理地址按运行时规则构建,会去重并附加 ?beta=true
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/messages?beta=true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,14 +431,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/messages#frag"
|
||||
"https://api.example.com/v1/messages?beta=true#frag"
|
||||
);
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_url_detection_by_api_format() {
|
||||
// anthropic 格式:只有 /messages 结尾才是全链接
|
||||
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",
|
||||
@@ -391,9 +451,8 @@ mod tests {
|
||||
"https://api.example.com/v1/chat/completions",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert!(!preview.is_full_url); // /chat/completions 对于 anthropic 格式不是全链接
|
||||
assert!(preview.is_full_url);
|
||||
|
||||
// openai_chat 格式:只有 /chat/completions 结尾才是全链接
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/chat/completions",
|
||||
@@ -406,6 +465,6 @@ mod tests {
|
||||
"https://api.example.com/v1/messages",
|
||||
Some("openai_chat"),
|
||||
);
|
||||
assert!(!preview.is_full_url); // /messages 对于 openai_chat 格式不是全链接
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
} from "@/config/opencodeProviderPresets";
|
||||
import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
import {
|
||||
applyTemplateValues,
|
||||
extractCodexWireApi,
|
||||
setCodexWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
@@ -46,7 +50,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||
import { CodexFormFields } from "./CodexFormFields";
|
||||
import { CodexFormFields, type CodexApiFormat } from "./CodexFormFields";
|
||||
import { GeminiFormFields } from "./GeminiFormFields";
|
||||
import { OmoFormFields } from "./OmoFormFields";
|
||||
import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
|
||||
@@ -357,14 +361,26 @@ export function ProviderForm({
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
const [localClaudeApiFormat, setLocalClaudeApiFormat] =
|
||||
useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat === "openai_chat"
|
||||
? "openai_chat"
|
||||
: "anthropic";
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
const [localCodexApiFormat, setLocalCodexApiFormat] =
|
||||
useState<CodexApiFormat>(() => {
|
||||
if (appId !== "codex") return "responses";
|
||||
if (initialData?.meta?.apiFormat === "chat") return "chat";
|
||||
if (initialData?.meta?.apiFormat === "responses") return "responses";
|
||||
|
||||
const initialConfig = initialData?.settingsConfig?.config;
|
||||
if (typeof initialConfig === "string") {
|
||||
return extractCodexWireApi(initialConfig) ?? "responses";
|
||||
}
|
||||
return "responses";
|
||||
});
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
@@ -374,6 +390,7 @@ export function ProviderForm({
|
||||
codexModelName,
|
||||
codexAuthError,
|
||||
setCodexAuth,
|
||||
setCodexConfig,
|
||||
handleCodexApiKeyChange,
|
||||
handleCodexBaseUrlChange,
|
||||
handleCodexModelNameChange,
|
||||
@@ -392,6 +409,18 @@ export function ProviderForm({
|
||||
[originalHandleCodexConfigChange, debouncedValidate],
|
||||
);
|
||||
|
||||
const handleClaudeApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalClaudeApiFormat(format);
|
||||
}, []);
|
||||
|
||||
const handleCodexApiFormatChange = useCallback(
|
||||
(format: CodexApiFormat) => {
|
||||
setLocalCodexApiFormat(format);
|
||||
setCodexConfig((prev) => setCodexWireApi(prev, format));
|
||||
},
|
||||
[setCodexConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
|
||||
const template = getCodexCustomTemplate();
|
||||
@@ -1166,6 +1195,13 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
const providerApiFormat =
|
||||
appId === "claude" && category !== "official"
|
||||
? localClaudeApiFormat
|
||||
: appId === "codex" && category !== "official"
|
||||
? localCodexApiFormat
|
||||
: undefined;
|
||||
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
payload.meta = {
|
||||
@@ -1180,10 +1216,7 @@ export function ProviderForm({
|
||||
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
|
||||
? pricingConfig.pricingModelSource
|
||||
: undefined,
|
||||
apiFormat:
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
: undefined,
|
||||
apiFormat: providerApiFormat,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -1277,6 +1310,9 @@ export function ProviderForm({
|
||||
|
||||
if (appId === "codex") {
|
||||
const template = getCodexCustomTemplate();
|
||||
setLocalCodexApiFormat(
|
||||
extractCodexWireApi(template.config) ?? "responses",
|
||||
);
|
||||
resetCodexConfig(template.auth, template.config);
|
||||
}
|
||||
if (appId === "gemini") {
|
||||
@@ -1311,6 +1347,7 @@ export function ProviderForm({
|
||||
const auth = preset.auth ?? {};
|
||||
const config = preset.config ?? "";
|
||||
|
||||
setLocalCodexApiFormat(extractCodexWireApi(config) ?? "responses");
|
||||
resetCodexConfig(auth, config);
|
||||
|
||||
form.reset({
|
||||
@@ -1381,9 +1418,9 @@ export function ProviderForm({
|
||||
);
|
||||
|
||||
if (preset.apiFormat) {
|
||||
setLocalApiFormat(preset.apiFormat);
|
||||
setLocalClaudeApiFormat(preset.apiFormat);
|
||||
} else {
|
||||
setLocalApiFormat("anthropic");
|
||||
setLocalClaudeApiFormat("anthropic");
|
||||
}
|
||||
|
||||
form.reset({
|
||||
@@ -1518,8 +1555,8 @@ export function ProviderForm({
|
||||
defaultOpusModel={defaultOpusModel}
|
||||
onModelChange={handleModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localApiFormat}
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiFormat={localClaudeApiFormat}
|
||||
onApiFormatChange={handleClaudeApiFormatChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1547,6 +1584,9 @@ export function ProviderForm({
|
||||
modelName={codexModelName}
|
||||
onModelNameChange={handleCodexModelNameChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
shouldShowApiFormatSelector={category !== "official"}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export function EndpointField({
|
||||
// 调用后端 API 获取 URL 预览
|
||||
useEffect(() => {
|
||||
if (!value || !appType || !showUrlPreview) {
|
||||
// 标记当前所有已发出的请求为过期,避免旧请求回写
|
||||
lastRequestIdRef.current += 1;
|
||||
setUrlPreview(null);
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export function EndpointField({
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
|
||||
{t("providerForm.directRequestUrlDesc", {
|
||||
defaultValue: "CLI 硬拼接默认后缀后的实际请求地址",
|
||||
defaultValue: "CLI 直连模式下的实际请求地址",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { Radio, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -14,6 +15,7 @@ import { toast } from "sonner";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
import type { Provider } from "@/types";
|
||||
import { extractProviderBaseUrl } from "@/utils/providerBaseUrl";
|
||||
|
||||
interface ProxyToggleProps {
|
||||
className?: string;
|
||||
@@ -21,48 +23,13 @@ interface ProxyToggleProps {
|
||||
currentProvider?: Provider | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 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" || appId === "opencode") {
|
||||
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 [isCheckingRequirement, setIsCheckingRequirement] = useState(false);
|
||||
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
|
||||
useProxyStatus();
|
||||
|
||||
@@ -73,36 +40,45 @@ export function ProxyToggle({
|
||||
currentProvider &&
|
||||
currentProvider.category !== "official"
|
||||
) {
|
||||
const baseUrl = extractBaseUrl(currentProvider, activeApp);
|
||||
const baseUrl = extractProviderBaseUrl(currentProvider, activeApp);
|
||||
const apiFormat = currentProvider.meta?.apiFormat;
|
||||
setIsCheckingRequirement(true);
|
||||
try {
|
||||
let proxyRequirement: string | null = null;
|
||||
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const proxyRequirement = await proxyApi.checkProxyRequirement(
|
||||
// 先按 API 格式做硬性判断(baseUrl 缺失时仍需提示)
|
||||
if (activeApp === "claude" && apiFormat === "openai_chat") {
|
||||
proxyRequirement = "openai_chat_format";
|
||||
}
|
||||
|
||||
if (!proxyRequirement && baseUrl) {
|
||||
proxyRequirement = await proxyApi.checkProxyRequirement(
|
||||
activeApp,
|
||||
baseUrl,
|
||||
apiFormat,
|
||||
);
|
||||
|
||||
if (proxyRequirement) {
|
||||
const warningKey =
|
||||
proxyRequirement === "openai_chat_format"
|
||||
? "notifications.openAIChatFormatWarningOnDisable"
|
||||
: proxyRequirement === "url_mismatch"
|
||||
? "notifications.urlMismatchWarningOnDisable"
|
||||
: "notifications.fullUrlWarningOnDisable";
|
||||
|
||||
toast.warning(
|
||||
t(warningKey, {
|
||||
defaultValue:
|
||||
"当前供应商配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
|
||||
}),
|
||||
{ duration: 6000, closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check proxy requirement:", error);
|
||||
}
|
||||
|
||||
if (proxyRequirement) {
|
||||
const warningKey =
|
||||
proxyRequirement === "openai_chat_format"
|
||||
? "notifications.openAIChatFormatWarningOnDisable"
|
||||
: proxyRequirement === "url_mismatch"
|
||||
? "notifications.urlMismatchWarningOnDisable"
|
||||
: "notifications.fullUrlWarningOnDisable";
|
||||
|
||||
toast.warning(
|
||||
t(warningKey, {
|
||||
defaultValue:
|
||||
"当前供应商配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
|
||||
}),
|
||||
{ duration: 6000, closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check proxy requirement:", error);
|
||||
} finally {
|
||||
setIsCheckingRequirement(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +131,7 @@ export function ProxyToggle({
|
||||
)}
|
||||
title={tooltipText}
|
||||
>
|
||||
{isPending ? (
|
||||
{isPending || isCheckingRequirement ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Radio
|
||||
@@ -170,7 +146,7 @@ export function ProxyToggle({
|
||||
<Switch
|
||||
checked={takeoverEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
disabled={isPending || isCheckingRequirement}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,55 +12,7 @@ import {
|
||||
useSwitchProviderMutation,
|
||||
} from "@/lib/query";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
/**
|
||||
* 从 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" || appId === "opencode") {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
import { extractProviderBaseUrl } from "@/utils/providerBaseUrl";
|
||||
|
||||
interface UseProviderActionsOptions {
|
||||
/** 代理服务是否正在运行 */
|
||||
@@ -160,13 +112,18 @@ export function useProviderActions(
|
||||
}
|
||||
|
||||
// 提取 base URL 和 API 格式
|
||||
const baseUrl = extractBaseUrl(provider, activeApp);
|
||||
const baseUrl = extractProviderBaseUrl(provider, activeApp);
|
||||
const apiFormat = provider.meta?.apiFormat;
|
||||
|
||||
// 调用后端 API 检查是否需要代理(前后端使用相同逻辑)
|
||||
let proxyRequirement: string | null = null;
|
||||
let proxyRequirementCheckFailed = false;
|
||||
if (baseUrl) {
|
||||
// 先按 API 格式做硬性判断(baseUrl 缺失时仍需拦截)
|
||||
if (activeApp === "claude" && apiFormat === "openai_chat") {
|
||||
proxyRequirement = "openai_chat_format";
|
||||
}
|
||||
|
||||
if (!proxyRequirement && baseUrl) {
|
||||
try {
|
||||
proxyRequirement = await proxyApi.checkProxyRequirement(
|
||||
activeApp,
|
||||
|
||||
+6
-2
@@ -140,10 +140,14 @@ export interface ProviderMeta {
|
||||
costMultiplier?: string;
|
||||
// 供应商计费模式来源
|
||||
pricingModelSource?: string;
|
||||
// Claude API 格式(仅 Claude 供应商使用)
|
||||
// 供应商 API 格式
|
||||
// Claude:
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
apiFormat?: "anthropic" | "openai_chat";
|
||||
// Codex:
|
||||
// - "responses": OpenAI Responses API
|
||||
// - "chat": OpenAI Chat Completions API
|
||||
apiFormat?: "anthropic" | "openai_chat" | "responses" | "chat";
|
||||
}
|
||||
|
||||
// Skill 同步方式
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { Provider } from "@/types";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
|
||||
/**
|
||||
* 从 provider 配置中提取 base URL(用于代理检查/预览)
|
||||
*/
|
||||
export function extractProviderBaseUrl(
|
||||
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" || appId === "opencode") {
|
||||
const tomlConfig = config?.config;
|
||||
if (typeof tomlConfig === "string") {
|
||||
return extractCodexBaseUrl(tomlConfig) ?? 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;
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,58 @@ export const setCodexBaseUrl = (
|
||||
return `${prefix}${replacementLine}\n`;
|
||||
};
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 wire_api 字段(responses/chat)
|
||||
export const extractCodexWireApi = (
|
||||
configText: string | undefined | null,
|
||||
): "responses" | "chat" | undefined => {
|
||||
try {
|
||||
const raw = typeof configText === "string" ? configText : "";
|
||||
const text = normalizeQuotes(raw);
|
||||
if (!text) return undefined;
|
||||
|
||||
const m = text.match(/^wire_api\s*=\s*(['"])(responses|chat)\1/m);
|
||||
if (!m || !m[2]) return undefined;
|
||||
return m[2] === "chat" ? "chat" : "responses";
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 在 Codex 的 TOML 配置文本中写入或更新 wire_api 字段
|
||||
export const setCodexWireApi = (
|
||||
configText: string,
|
||||
wireApi: "responses" | "chat",
|
||||
): string => {
|
||||
const normalizedText = normalizeQuotes(configText);
|
||||
const replacementLine = `wire_api = "${wireApi}"`;
|
||||
const pattern = /^wire_api\s*=\s*(["'])(responses|chat)\1/m;
|
||||
|
||||
if (pattern.test(normalizedText)) {
|
||||
return normalizedText.replace(pattern, replacementLine);
|
||||
}
|
||||
|
||||
// 优先插入到 base_url 后,提升可读性
|
||||
const baseUrlPattern = /^base_url\s*=\s*["'][^"']+["']/m;
|
||||
const match = normalizedText.match(baseUrlPattern);
|
||||
if (match && match.index !== undefined) {
|
||||
const endOfLine = normalizedText.indexOf("\n", match.index);
|
||||
if (endOfLine !== -1) {
|
||||
return (
|
||||
normalizedText.slice(0, endOfLine + 1) +
|
||||
replacementLine +
|
||||
"\n" +
|
||||
normalizedText.slice(endOfLine + 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prefix =
|
||||
normalizedText && !normalizedText.endsWith("\n")
|
||||
? `${normalizedText}\n`
|
||||
: normalizedText;
|
||||
return `${prefix}${replacementLine}\n`;
|
||||
};
|
||||
|
||||
// ========== Codex model name utils ==========
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)
|
||||
|
||||
Reference in New Issue
Block a user