feat: route Codex Chat providers through Stream Check

Codex Chat providers (apiFormat=openai_chat, e.g. DeepSeek, MiniMax, Kimi)
were incorrectly probed via /v1/responses by Stream Check, which the upstream
rejects. Detect chat routing via codex_provider_uses_chat_completions and
issue the probe against /chat/completions with a Chat-shaped body.

Also align URL fallback order with the production CodexAdapter::build_url:
origin-only base URLs (https://api.deepseek.com) must hit /v1/<endpoint>
first; bare /<endpoint> only as a fallback. The previous order made any
non-404 error on the bare path (401/400/405) flag the provider as down even
though /v1/<endpoint> would have succeeded.

- Lift origin-only detection into proxy::providers::is_origin_only_url so
  both build_url and Stream Check share a single source of truth.
- Collapse resolve_codex_stream_urls and resolve_codex_chat_stream_urls into
  a generic resolve_codex_endpoint_urls(base_url, is_full_url, endpoint),
  which also gives the Responses path a symmetric "full endpoint without
  is_full_url flag" fallback for free.
- Restrict reasoning_effort propagation in the Chat branch to OpenAI o-series
  models, mirroring transform_codex_chat's runtime check.
This commit is contained in:
Jason
2026-05-21 09:14:52 +08:00
Unverified
parent 955904f719
commit 72bc912e0d
3 changed files with 181 additions and 19 deletions
+11 -6
View File
@@ -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<String> {
let doc = config_text.parse::<TomlValue>().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,直接拼接
+1
View File
@@ -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;
+169 -13
View File
@@ -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-seriesDeepSeek、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<String> {
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<String> {
Self::resolve_codex_endpoint_urls(base_url, is_full_url, "chat/completions")
}
/// 与 `CodexAdapter::build_url` 优先级对齐的 stream check URL 列表构造。
///
/// 纯 origin 在生产路径上会自动补 `/v1`,所以 Stream Check 必须优先走
/// `<base>/v1/<endpoint>`。否则上游对 bare 路径返回 401/400/405(非 404
/// 时不会触发循环里的 fallback,会把可用供应商误判为不可用。
fn resolve_codex_endpoint_urls(
base_url: &str,
is_full_url: bool,
endpoint: &str,
) -> Vec<String> {
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)生产路径不会自动补 /v1Stream 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 时(非 404fallback 不会触发,会误判为不可用。
#[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"]);
}
}