mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +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 处理器
|
// 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(),
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user