mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(codex): complete full URL support
This commit is contained in:
@@ -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 处理器
|
||||
// ============================================================================
|
||||
@@ -293,11 +300,13 @@ async fn handle_claude_transform(
|
||||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||
pub async fn handle_chat_completions(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/chat/completions");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -308,7 +317,7 @@ pub async fn handle_chat_completions(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/chat/completions",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -334,11 +343,13 @@ pub async fn handle_chat_completions(
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -349,7 +360,7 @@ pub async fn handle_responses(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -375,11 +386,13 @@ pub async fn handle_responses(
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -390,7 +403,7 @@ pub async fn handle_responses_compact(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses/compact",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
|
||||
@@ -225,6 +225,7 @@ impl StreamCheckService {
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -441,18 +442,14 @@ impl StreamCheckService {
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
|
||||
// Responses 端点为 `/responses`。
|
||||
//
|
||||
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
|
||||
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
|
||||
let urls = if base.ends_with("/v1") {
|
||||
vec![format!("{base}/responses")]
|
||||
} else {
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
};
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let urls = Self::resolve_codex_stream_urls(base_url, is_full_url);
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||
@@ -743,6 +740,20 @@ impl StreamCheckService {
|
||||
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)]
|
||||
@@ -905,4 +916,35 @@ mod tests {
|
||||
|
||||
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;
|
||||
codexBaseUrl: string;
|
||||
onBaseUrlChange: (url: string) => void;
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
@@ -49,6 +51,8 @@ export function CodexFormFields({
|
||||
shouldShowSpeedTest,
|
||||
codexBaseUrl,
|
||||
onBaseUrlChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
@@ -93,6 +97,9 @@ export function CodexFormFields({
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
|
||||
hint={t("providerForm.codexApiHint")}
|
||||
showFullUrlToggle
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -162,8 +162,9 @@ export function ProviderForm({
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
const supportsFullUrl = appId === "claude" || appId === "codex";
|
||||
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
|
||||
if (appId !== "claude") return false;
|
||||
if (!supportsFullUrl) return false;
|
||||
return initialData?.meta?.isFullUrl ?? false;
|
||||
});
|
||||
|
||||
@@ -206,7 +207,7 @@ export function ProviderForm({
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setLocalIsFullUrl(
|
||||
appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
@@ -219,7 +220,7 @@ export function ProviderForm({
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
});
|
||||
}, [appId, initialData]);
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
() => ({
|
||||
@@ -949,7 +950,7 @@ export function ProviderForm({
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
appId === "claude" && category !== "official" && localIsFullUrl
|
||||
supportsFullUrl && category !== "official" && localIsFullUrl
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
@@ -1432,6 +1433,8 @@ export function ProviderForm({
|
||||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||
codexBaseUrl={codexBaseUrl}
|
||||
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
isEndpointModalOpen={isCodexEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
||||
onCustomEndpointsChange={
|
||||
|
||||
@@ -139,13 +139,17 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
// 切换供应商
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
const requiresProxyForSwitch =
|
||||
!isProxyRunning &&
|
||||
provider.category !== "official" &&
|
||||
(provider.meta?.isFullUrl ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses") &&
|
||||
!isProxyRunning
|
||||
((activeApp === "claude" &&
|
||||
(provider.meta?.isFullUrl ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses")) ||
|
||||
(activeApp === "codex" && provider.meta?.isFullUrl));
|
||||
|
||||
if (
|
||||
requiresProxyForSwitch
|
||||
) {
|
||||
toast.warning(
|
||||
t("notifications.proxyRequiredForSwitch", {
|
||||
|
||||
@@ -213,6 +213,28 @@ describe("useProviderActions", () => {
|
||||
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 () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
settingsApiGetMock.mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user