diff --git a/src-tauri/src/proxy/url_builder.rs b/src-tauri/src/proxy/url_builder.rs
index e9fe14fbe..d62db0bc6 100644
--- a/src-tauri/src/proxy/url_builder.rs
+++ b/src-tauri/src/proxy/url_builder.rs
@@ -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);
}
}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx
index b60e18a86..e32ff30b4 100644
--- a/src/components/providers/forms/ProviderForm.tsx
+++ b/src/components/providers/forms/ProviderForm.tsx
@@ -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
{t("providerForm.directRequestUrlDesc", { - defaultValue: "CLI 硬拼接默认后缀后的实际请求地址", + defaultValue: "CLI 直连模式下的实际请求地址", })}
diff --git a/src/components/proxy/ProxyToggle.tsx b/src/components/proxy/ProxyToggle.tsx index 8d9a4c3cb..f1b5008ee 100644 --- a/src/components/proxy/ProxyToggle.tsx +++ b/src/components/proxy/ProxyToggle.tsx @@ -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 ? (