diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 02c8d89c3..8d4d0eb2c 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -166,6 +166,16 @@ fn is_chat_completions_url(value: &str) -> bool { .ends_with("/chat/completions") } +/// `scheme://host` 之后没有路径段的纯 origin 形式。`build_url` 在这种情况下 +/// 会自动补 `/v1`;Stream Check 等同步生产路径的代码也需要同一判定。 +pub fn is_origin_only_url(value: &str) -> bool { + let trimmed = value.trim_end_matches('/'); + match trimmed.split_once("://") { + Some((_scheme, rest)) => !rest.contains('/'), + None => !trimmed.contains('/'), + } +} + fn extract_codex_wire_api_from_toml(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; @@ -342,12 +352,7 @@ impl ProviderAdapter for CodexAdapter { // 检查 base_url 是否已经包含 /v1 let already_has_v1 = base_trimmed.ends_with("/v1"); - - // 检查是否是纯 origin(没有路径部分) - let origin_only = match base_trimmed.split_once("://") { - Some((_scheme, rest)) => !rest.contains('/'), - None => !base_trimmed.contains('/'), - }; + let origin_only = is_origin_only_url(base_trimmed); let mut url = if already_has_v1 { // 已经有 /v1,直接拼接 diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 6fc91b0c8..21345f640 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -47,6 +47,7 @@ pub use claude::{ pub use codex::CodexAdapter; pub use codex::{ apply_codex_chat_upstream_model, codex_provider_upstream_model, + codex_provider_uses_chat_completions, is_origin_only_url, should_convert_codex_responses_to_chat, }; pub use gemini::GeminiAdapter; diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 6e721e8b3..4f2b9a6ee 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -532,7 +532,15 @@ impl StreamCheckService { .as_ref() .and_then(|meta| meta.is_full_url) .unwrap_or(false); - let urls = Self::resolve_codex_stream_urls(base_url, is_full_url); + // 当 provider 的 api_format 标记为 openai_chat 时,上游不接受 Responses API; + // 必须改打 /chat/completions 并发送 Chat 格式 body,否则 Stream Check 与代理路径不一致, + // 会把"实际可用"的供应商误报为不可用(典型如 DeepSeek、MiniMax、Kimi 等 Chat 兼容厂商)。 + let uses_chat = crate::proxy::providers::codex_provider_uses_chat_completions(provider); + let urls = if uses_chat { + Self::resolve_codex_chat_stream_urls(base_url, is_full_url) + } else { + 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); @@ -541,16 +549,33 @@ impl StreamCheckService { let os_name = Self::get_os_name(); let arch_name = Self::get_arch_name(); - // Responses API 请求体格式 (input 必须是数组) - let mut body = json!({ - "model": actual_model, - "input": [{ "role": "user", "content": test_prompt }], - "stream": true - }); + let mut body = if uses_chat { + // Chat Completions 请求体(与 transform_codex_chat::responses_to_chat_completions 对齐) + json!({ + "model": actual_model, + "messages": [{ "role": "user", "content": test_prompt }], + "max_tokens": 1, + "stream": true + }) + } else { + // Responses API 请求体格式 (input 必须是数组) + json!({ + "model": actual_model, + "input": [{ "role": "user", "content": test_prompt }], + "stream": true + }) + }; - // 如果是推理模型,添加 reasoning_effort + // Chat 路径只对 OpenAI o-series 透传 reasoning_effort,与 transform_codex_chat + // 一致;非 o-series(DeepSeek、Kimi 等)收到未知字段会 400。 if let Some(effort) = reasoning_effort { - body["reasoning"] = json!({ "effort": effort }); + if uses_chat + && crate::proxy::providers::transform::supports_reasoning_effort(&actual_model) + { + body["reasoning_effort"] = json!(effort); + } else if !uses_chat { + body["reasoning"] = json!({ "effort": effort }); + } } for (i, url) in urls.iter().enumerate() { @@ -1510,17 +1535,54 @@ impl StreamCheckService { } } + /// Codex Responses 流式 URL 构造(薄包装,详见 `resolve_codex_endpoint_urls`)。 fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec { + Self::resolve_codex_endpoint_urls(base_url, is_full_url, "responses") + } + + /// Codex Chat Completions 流式 URL 构造(薄包装,详见 `resolve_codex_endpoint_urls`)。 + fn resolve_codex_chat_stream_urls(base_url: &str, is_full_url: bool) -> Vec { + Self::resolve_codex_endpoint_urls(base_url, is_full_url, "chat/completions") + } + + /// 与 `CodexAdapter::build_url` 优先级对齐的 stream check URL 列表构造。 + /// + /// 纯 origin 在生产路径上会自动补 `/v1`,所以 Stream Check 必须优先走 + /// `/v1/`。否则上游对 bare 路径返回 401/400/405(非 404) + /// 时不会触发循环里的 fallback,会把可用供应商误判为不可用。 + fn resolve_codex_endpoint_urls( + base_url: &str, + is_full_url: bool, + endpoint: &str, + ) -> Vec { if is_full_url { return vec![base_url.to_string()]; } let base = base_url.trim_end_matches('/'); + let lower = base.to_ascii_lowercase(); + let endpoint_suffix = format!("/{endpoint}"); + let endpoint_lower = endpoint_suffix.to_ascii_lowercase(); + + // 用户在 base_url 里写了完整 endpoint 但忘开 is_full_url 的兜底 + if lower.ends_with(&endpoint_lower) { + return vec![base.to_string()]; + } if base.ends_with("/v1") { - vec![format!("{base}/responses")] + return vec![format!("{base}{endpoint_suffix}")]; + } + + if crate::proxy::providers::is_origin_only_url(base) { + vec![ + format!("{base}/v1{endpoint_suffix}"), + format!("{base}{endpoint_suffix}"), + ] } else { - vec![format!("{base}/responses"), format!("{base}/v1/responses")] + vec![ + format!("{base}{endpoint_suffix}"), + format!("{base}/v1{endpoint_suffix}"), + ] } } @@ -1995,16 +2057,110 @@ mod tests { assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); } + /// 纯 origin 必须优先 /v1/responses(与 CodexAdapter::build_url 一致)。 + /// OpenAI 官方 /responses 实际挂在 /v1 下,bare 路径只在用户配置错误的 + /// 反代上才可能命中,作为 fallback 保留即可。 #[test] - fn test_resolve_codex_stream_urls_for_origin_base() { + fn test_resolve_codex_stream_urls_for_origin_base_prioritizes_v1() { 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", + "https://api.openai.com/responses", ] ); } + + /// 自定义前缀(如 /openai)生产路径不会自动补 /v1,Stream Check 应先打 + /// 不带 /v1 的版本与之对齐;/v1 作为 misconfigured 兜底。 + #[test] + fn test_resolve_codex_stream_urls_for_custom_prefix() { + let urls = + StreamCheckService::resolve_codex_stream_urls("https://relay.example/openai", false); + + assert_eq!( + urls, + vec![ + "https://relay.example/openai/responses", + "https://relay.example/openai/v1/responses", + ] + ); + } + + #[test] + fn test_resolve_codex_stream_urls_recognizes_full_endpoint_without_flag() { + let urls = StreamCheckService::resolve_codex_stream_urls( + "https://api.openai.com/v1/responses", + false, + ); + + assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); + } + + #[test] + fn test_resolve_codex_chat_stream_urls_for_v1_base() { + let urls = StreamCheckService::resolve_codex_chat_stream_urls( + "https://api.deepseek.com/v1", + false, + ); + + assert_eq!(urls, vec!["https://api.deepseek.com/v1/chat/completions"]); + } + + /// 纯 origin 必须优先 /v1/chat/completions,与 CodexAdapter::build_url 一致; + /// bare /chat/completions 仅作为 fallback。如果颠倒了优先级,上游对 bare + /// 路径返回 401/400/405 时(非 404)fallback 不会触发,会误判为不可用。 + #[test] + fn test_resolve_codex_chat_stream_urls_for_origin_base_prioritizes_v1() { + let urls = + StreamCheckService::resolve_codex_chat_stream_urls("https://api.deepseek.com", false); + + assert_eq!( + urls, + vec![ + "https://api.deepseek.com/v1/chat/completions", + "https://api.deepseek.com/chat/completions", + ] + ); + } + + /// 自定义前缀(路径中已经包含段如 `/openai`)生产路径不会自动补 /v1。 + /// Stream Check 应先打不带 /v1 的版本,与 build_url 行为一致。 + #[test] + fn test_resolve_codex_chat_stream_urls_for_custom_prefix() { + let urls = + StreamCheckService::resolve_codex_chat_stream_urls("https://example.com/openai", false); + + assert_eq!( + urls, + vec![ + "https://example.com/openai/chat/completions", + "https://example.com/openai/v1/chat/completions", + ] + ); + } + + #[test] + fn test_resolve_codex_chat_stream_urls_for_full_url_mode() { + let urls = StreamCheckService::resolve_codex_chat_stream_urls( + "https://relay.example/custom/chat/completions", + true, + ); + + assert_eq!(urls, vec!["https://relay.example/custom/chat/completions"]); + } + + /// 用户在 base_url 里直接写完整 chat/completions endpoint 但忘开 is_full_url 时, + /// 不应该再追加 `/chat/completions` 后缀。 + #[test] + fn test_resolve_codex_chat_stream_urls_recognizes_full_endpoint_without_flag() { + let urls = StreamCheckService::resolve_codex_chat_stream_urls( + "https://api.deepseek.com/v1/chat/completions", + false, + ); + + assert_eq!(urls, vec!["https://api.deepseek.com/v1/chat/completions"]); + } }