feat(codex): complete full URL support

This commit is contained in:
YoVinchen
2026-03-19 15:27:23 +08:00
parent c2120dc8c3
commit 8a26a091a9
6 changed files with 115 additions and 24 deletions
+16 -3
View File
@@ -286,6 +286,13 @@ async fn handle_claude_transform(
}) })
} }
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
match uri.query() {
Some(query) => format!("{endpoint}?{query}"),
None => endpoint.to_string(),
}
}
// ============================================================================ // ============================================================================
// Codex API 处理器 // Codex API 处理器
// ============================================================================ // ============================================================================
@@ -293,11 +300,13 @@ async fn handle_claude_transform(
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI /// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI
pub async fn handle_chat_completions( pub async fn handle_chat_completions(
State(state): State<ProxyState>, State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/chat/completions");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -308,7 +317,7 @@ pub async fn handle_chat_completions(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/chat/completions", &endpoint,
body, body,
headers, headers,
ctx.get_providers(), ctx.get_providers(),
@@ -334,11 +343,13 @@ pub async fn handle_chat_completions(
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) /// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
pub async fn handle_responses( pub async fn handle_responses(
State(state): State<ProxyState>, State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -349,7 +360,7 @@ pub async fn handle_responses(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/responses", &endpoint,
body, body,
headers, headers,
ctx.get_providers(), ctx.get_providers(),
@@ -375,11 +386,13 @@ pub async fn handle_responses(
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传) /// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
pub async fn handle_responses_compact( pub async fn handle_responses_compact(
State(state): State<ProxyState>, State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -390,7 +403,7 @@ pub async fn handle_responses_compact(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/responses/compact", &endpoint,
body, body,
headers, headers,
ctx.get_providers(), ctx.get_providers(),
+53 -11
View File
@@ -225,6 +225,7 @@ impl StreamCheckService {
&model_to_test, &model_to_test,
test_prompt, test_prompt,
request_timeout, request_timeout,
provider,
) )
.await .await
} }
@@ -441,18 +442,14 @@ impl StreamCheckService {
model: &str, model: &str,
test_prompt: &str, test_prompt: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
provider: &Provider,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let is_full_url = provider
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀), .meta
// Responses 端点为 `/responses`。 .as_ref()
// .and_then(|meta| meta.is_full_url)
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。 .unwrap_or(false);
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。 let urls = Self::resolve_codex_stream_urls(base_url, is_full_url);
let urls = if base.ends_with("/v1") {
vec![format!("{base}/responses")]
} else {
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) // 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
@@ -743,6 +740,20 @@ impl StreamCheckService {
format!("{base}/v1/messages") format!("{base}/v1/messages")
} }
} }
fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec<String> {
if is_full_url {
return vec![base_url.to_string()];
}
let base = base_url.trim_end_matches('/');
if base.ends_with("/v1") {
vec![format!("{base}/responses")]
} else {
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -905,4 +916,35 @@ mod tests {
assert_eq!(url, "https://api.anthropic.com/v1/messages"); assert_eq!(url, "https://api.anthropic.com/v1/messages");
} }
#[test]
fn test_resolve_codex_stream_urls_for_full_url_mode() {
let urls = StreamCheckService::resolve_codex_stream_urls(
"https://relay.example/custom/responses",
true,
);
assert_eq!(urls, vec!["https://relay.example/custom/responses"]);
}
#[test]
fn test_resolve_codex_stream_urls_for_v1_base() {
let urls =
StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false);
assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]);
}
#[test]
fn test_resolve_codex_stream_urls_for_origin_base() {
let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false);
assert_eq!(
urls,
vec![
"https://api.openai.com/responses",
"https://api.openai.com/v1/responses",
]
);
}
} }
@@ -22,6 +22,8 @@ interface CodexFormFieldsProps {
shouldShowSpeedTest: boolean; shouldShowSpeedTest: boolean;
codexBaseUrl: string; codexBaseUrl: string;
onBaseUrlChange: (url: string) => void; onBaseUrlChange: (url: string) => void;
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
@@ -49,6 +51,8 @@ export function CodexFormFields({
shouldShowSpeedTest, shouldShowSpeedTest,
codexBaseUrl, codexBaseUrl,
onBaseUrlChange, onBaseUrlChange,
isFullUrl,
onFullUrlChange,
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
@@ -93,6 +97,9 @@ export function CodexFormFields({
onChange={onBaseUrlChange} onChange={onBaseUrlChange}
placeholder={t("providerForm.codexApiEndpointPlaceholder")} placeholder={t("providerForm.codexApiEndpointPlaceholder")}
hint={t("providerForm.codexApiHint")} hint={t("providerForm.codexApiHint")}
showFullUrlToggle
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
onManageClick={() => onEndpointModalToggle(true)} onManageClick={() => onEndpointModalToggle(true)}
/> />
)} )}
@@ -162,8 +162,9 @@ export function ProviderForm({
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>( const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true, () => initialData?.meta?.endpointAutoSelect ?? true,
); );
const supportsFullUrl = appId === "claude" || appId === "codex";
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => { const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (appId !== "claude") return false; if (!supportsFullUrl) return false;
return initialData?.meta?.isFullUrl ?? false; return initialData?.meta?.isFullUrl ?? false;
}); });
@@ -206,7 +207,7 @@ export function ProviderForm({
} }
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setLocalIsFullUrl( setLocalIsFullUrl(
appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false, supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
); );
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
@@ -219,7 +220,7 @@ export function ProviderForm({
initialData?.meta?.pricingModelSource, initialData?.meta?.pricingModelSource,
), ),
}); });
}, [appId, initialData]); }, [appId, initialData, supportsFullUrl]);
const defaultValues: ProviderFormData = useMemo( const defaultValues: ProviderFormData = useMemo(
() => ({ () => ({
@@ -949,7 +950,7 @@ export function ProviderForm({
? localApiKeyField ? localApiKeyField
: undefined, : undefined,
isFullUrl: isFullUrl:
appId === "claude" && category !== "official" && localIsFullUrl supportsFullUrl && category !== "official" && localIsFullUrl
? true ? true
: undefined, : undefined,
}; };
@@ -1432,6 +1433,8 @@ export function ProviderForm({
shouldShowSpeedTest={shouldShowSpeedTest} shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl} codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange} onBaseUrlChange={handleCodexBaseUrlChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
isEndpointModalOpen={isCodexEndpointModalOpen} isEndpointModalOpen={isCodexEndpointModalOpen}
onEndpointModalToggle={setIsCodexEndpointModalOpen} onEndpointModalToggle={setIsCodexEndpointModalOpen}
onCustomEndpointsChange={ onCustomEndpointsChange={
+10 -6
View File
@@ -139,13 +139,17 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
// 切换供应商 // 切换供应商
const switchProvider = useCallback( const switchProvider = useCallback(
async (provider: Provider) => { async (provider: Provider) => {
if ( const requiresProxyForSwitch =
activeApp === "claude" && !isProxyRunning &&
provider.category !== "official" && provider.category !== "official" &&
(provider.meta?.isFullUrl || ((activeApp === "claude" &&
provider.meta?.apiFormat === "openai_chat" || (provider.meta?.isFullUrl ||
provider.meta?.apiFormat === "openai_responses") && provider.meta?.apiFormat === "openai_chat" ||
!isProxyRunning provider.meta?.apiFormat === "openai_responses")) ||
(activeApp === "codex" && provider.meta?.isFullUrl));
if (
requiresProxyForSwitch
) { ) {
toast.warning( toast.warning(
t("notifications.proxyRequiredForSwitch", { t("notifications.proxyRequiredForSwitch", {
+22
View File
@@ -213,6 +213,28 @@ describe("useProviderActions", () => {
expect(settingsApiGetMock).not.toHaveBeenCalled(); expect(settingsApiGetMock).not.toHaveBeenCalled();
}); });
it("blocks switching Codex full URL providers when proxy is not running", async () => {
const { wrapper } = createWrapper();
const provider = createProvider({
category: "custom",
meta: {
isFullUrl: true,
},
});
const { result } = renderHook(() => useProviderActions("codex", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
expect(toastWarningMock).toHaveBeenCalledTimes(1);
expect(settingsApiGetMock).not.toHaveBeenCalled();
});
it("should sync plugin config when switching Claude provider with integration enabled", async () => { it("should sync plugin config when switching Claude provider with integration enabled", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined); switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({ settingsApiGetMock.mockResolvedValueOnce({