feat: adaptive reasoning detection for Codex Chat providers

Auto-detect each Chat-routed Codex provider's reasoning interface from
its name, base URL, and model, then inject the matching thinking
parameter without manual configuration:

- Platform-first inference (OpenRouter, SiliconFlow) overrides model
  rules, since the same model exposes different reasoning controls
  depending on the hosting platform.
- Effort tiers are forwarded only to providers that support them
  (DeepSeek, OpenRouter, and StepFun's step-3.5-flash-2603); on/off-only
  providers (Kimi, GLM, Qwen, MiniMax, MiMo, SiliconFlow) drop the level
  instead of sending a field the upstream rejects.
- OpenRouter uses the native reasoning:{effort} object, clamps max to
  xhigh (its enum has no max), and forwards an explicit effort:"none" so
  reasoning can be turned off.
- StepFun falls back to inference so per-model effort support is honored
  (the static preset would have forced effort on step-3.5-flash too).

Includes the Codex provider-form reasoning controls, i18n strings
(zh/en/ja), and response-side reasoning extraction.
This commit is contained in:
Jason
2026-05-21 22:29:18 +08:00
parent 5048ed632d
commit 44d9aabbf3
13 changed files with 1058 additions and 16 deletions
+6 -1
View File
@@ -1160,7 +1160,12 @@ impl RequestForwarder {
);
}
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
super::providers::transform_codex_chat::responses_to_chat_completions(mapped_body)?
let reasoning_config =
super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body);
super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning(
mapped_body,
reasoning_config.as_ref(),
)?
} else if needs_transform {
if adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
+297 -1
View File
@@ -6,7 +6,7 @@
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::provider::{CodexChatReasoningConfig, Provider};
use crate::proxy::error::ProxyError;
use regex::Regex;
use serde_json::Value as JsonValue;
@@ -147,6 +147,192 @@ pub fn apply_codex_chat_upstream_model(
Some(upstream_model)
}
pub fn resolve_codex_chat_reasoning_config(
provider: &Provider,
body: &JsonValue,
) -> Option<CodexChatReasoningConfig> {
if let Some(config) = provider
.meta
.as_ref()
.and_then(|meta| meta.codex_chat_reasoning.clone())
{
return Some(normalize_codex_chat_reasoning_config(config));
}
infer_codex_chat_reasoning_config(provider, body)
}
fn normalize_codex_chat_reasoning_config(
mut config: CodexChatReasoningConfig,
) -> CodexChatReasoningConfig {
if config.supports_effort.unwrap_or(false) && config.supports_thinking.is_none() {
config.supports_thinking = Some(true);
}
config
}
fn infer_codex_chat_reasoning_config(
provider: &Provider,
body: &JsonValue,
) -> Option<CodexChatReasoningConfig> {
let model = body
.get("model")
.and_then(|value| value.as_str())
.map(ToString::to_string)
.or_else(|| codex_provider_upstream_model(provider))
.unwrap_or_default()
.to_ascii_lowercase();
let base_url = provider
.settings_config
.get("base_url")
.or_else(|| provider.settings_config.get("baseURL"))
.and_then(|v| v.as_str())
.map(ToString::to_string)
.or_else(|| {
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_base_url_from_toml)
})
.unwrap_or_default()
.to_ascii_lowercase();
let name = provider.name.to_ascii_lowercase();
// 平台优先:聚合 / 托管平台的 reasoning 接口由平台的推理框架决定,而非模型官方实现,
// 因此先按平台标识(仅 name + base_url,不含 model 名)判定并覆盖模型规则。
if let Some(config) = infer_aggregator_platform_config(&name, &base_url) {
return Some(config);
}
let haystack = format!("{name} {base_url} {model}");
if haystack.contains("deepseek") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(true),
thinking_param: Some("thinking".to_string()),
effort_param: Some("reasoning_effort".to_string()),
effort_value_mode: Some("deepseek".to_string()),
output_format: Some("reasoning_content".to_string()),
});
}
// StepFun:仅 step-3.5-flash-2603 这一版支持 reasoning effortlow/high 两档),
// 其余 step 模型不暴露 effort,故 supports_effort 仅对含 "2603" 的模型置真。
// 第二个 OR 分支覆盖「经中转/聚合跑该模型、但平台 name/base_url 不含 stepfun」的情况。
if haystack.contains("stepfun") || haystack.contains("step-3.5-flash-2603") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(model.contains("2603")),
thinking_param: Some("none".to_string()),
effort_param: Some("reasoning_effort".to_string()),
effort_value_mode: Some("low_high".to_string()),
output_format: Some("reasoning".to_string()),
});
}
if haystack.contains("kimi") || haystack.contains("moonshot") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
if haystack.contains("glm") || haystack.contains("zhipu") || haystack.contains("z.ai") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
if haystack.contains("qwen") || haystack.contains("dashscope") || haystack.contains("bailian") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("enable_thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
if haystack.contains("minimax") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("reasoning_split".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_details".to_string()),
});
}
if haystack.contains("mimo") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
None
}
/// 聚合 / 托管平台的 reasoning 接口由平台决定:同一个模型在不同平台参数可能完全不同
/// DeepSeek 官方用 `thinking:{type}`、SiliconFlow 用 `enable_thinking`、
/// OpenRouter 用原生 `reasoning:{effort}` 对象)。仅以平台标识(name / base_url)判定,
/// 绝不掺入 model 名——model 名属于模型厂商,会把托管平台误判成模型官方接口。
fn infer_aggregator_platform_config(
name: &str,
base_url: &str,
) -> Option<CodexChatReasoningConfig> {
let platform = format!("{name} {base_url}");
// OpenRouter:用原生归一化对象 `reasoning: { effort }`(由 OpenRouter 翻译成各底层
// 模型的正确推理参数,比顶层 OpenAI 别名 reasoning_effort 覆盖面更全)。effort 走
// "openrouter" 值映射:枚举为 xhigh|high|medium|low|minimal,无 max——max 会触发
// `400 reasoning_effort: Invalid option`(见 openclaw#77350),故钳到 xhigh。
// 安全降级:不发 `thinking:{type}`OpenRouter 不认该字段),避免误配导致请求被拒。
if platform.contains("openrouter") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(false),
supports_effort: Some(true),
thinking_param: Some("none".to_string()),
effort_param: Some("reasoning.effort".to_string()),
effort_value_mode: Some("openrouter".to_string()),
output_format: Some("auto".to_string()),
});
}
// SiliconFlow:平台级统一 `enable_thinking`,思维回传 reasoning_content。
// 安全降级:不按 reasoning_effort 发 effort(平台用 thinking_budget 控制深度,
// 发 reasoning_effort 反而可能不被接受)。
if platform.contains("siliconflow") {
return Some(CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("enable_thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
});
}
None
}
fn is_chat_wire_api(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
@@ -642,4 +828,114 @@ wire_api = "responses"
assert_eq!(upstream_model.as_deref(), Some("kimi-k2"));
assert_eq!(body.get("model").and_then(|v| v.as_str()), Some("kimi-k2"));
}
#[test]
fn test_resolve_codex_chat_reasoning_infers_deepseek_effort_support() {
let provider = create_provider(json!({
"config": r#"
model_provider = "deepseek"
model = "deepseek-v4-pro"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com"
wire_api = "chat"
"#
}));
let config =
resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "deepseek-v4-pro" }))
.unwrap();
assert_eq!(config.supports_thinking, Some(true));
assert_eq!(config.supports_effort, Some(true));
assert_eq!(config.effort_value_mode.as_deref(), Some("deepseek"));
}
#[test]
fn test_resolve_codex_chat_reasoning_explicit_meta_overrides_inference() {
let mut provider = create_provider(json!({
"config": r#"
model_provider = "deepseek"
model = "deepseek-v4-pro"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com"
wire_api = "chat"
"#
}));
provider.meta = Some(crate::provider::ProviderMeta {
codex_chat_reasoning: Some(CodexChatReasoningConfig {
supports_thinking: Some(false),
supports_effort: Some(false),
thinking_param: Some("none".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("auto".to_string()),
}),
..Default::default()
});
let config =
resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "deepseek-v4-pro" }))
.unwrap();
assert_eq!(config.supports_thinking, Some(false));
assert_eq!(config.supports_effort, Some(false));
assert_eq!(config.thinking_param.as_deref(), Some("none"));
}
#[test]
fn test_resolve_codex_chat_reasoning_openrouter_platform_overrides_model() {
let provider = create_provider(json!({
"config": r#"
model_provider = "openrouter"
model = "deepseek/deepseek-chat-v3.1"
[model_providers.openrouter]
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
wire_api = "chat"
"#
}));
// 模型名含 "deepseek",但平台是 OpenRouter —— 平台规则必须覆盖模型规则。
let config = resolve_codex_chat_reasoning_config(
&provider,
&json!({ "model": "deepseek/deepseek-chat-v3.1" }),
)
.unwrap();
assert_eq!(config.thinking_param.as_deref(), Some("none"));
assert_eq!(config.effort_param.as_deref(), Some("reasoning.effort"));
assert_eq!(config.effort_value_mode.as_deref(), Some("openrouter"));
assert_eq!(config.supports_effort, Some(true));
}
#[test]
fn test_resolve_codex_chat_reasoning_siliconflow_platform_overrides_minimax() {
let provider = create_provider(json!({
"config": r#"
model_provider = "siliconflow"
model = "MiniMaxAI/MiniMax-M2.7"
[model_providers.siliconflow]
name = "SiliconFlow"
base_url = "https://api.siliconflow.cn/v1"
wire_api = "chat"
"#
}));
// 模型是 MiniMax(官方用 reasoning_split),但平台是 SiliconFlow —— 应走平台的 enable_thinking。
let config = resolve_codex_chat_reasoning_config(
&provider,
&json!({ "model": "MiniMaxAI/MiniMax-M2.7" }),
)
.unwrap();
assert_eq!(config.thinking_param.as_deref(), Some("enable_thinking"));
assert_eq!(config.supports_effort, Some(false));
assert_eq!(config.output_format.as_deref(), Some("reasoning_content"));
}
}
@@ -3,6 +3,8 @@ use serde_json::{json, Map, Value};
const THINK_OPEN_TAG: &str = "<think>";
const THINK_CLOSE_TAG: &str = "</think>";
// 穷举上游可能的 reasoning 回传字段,优先级:reasoning_content > reasoning(字符串/对象) > reasoning_details。
// 不依赖 provider meta 的 outputFormat 声明,因此对各家 Chat 兼容接口都能兜底提取。
pub(crate) fn extract_reasoning_field_text(value: &Value) -> Option<String> {
for key in ["reasoning_content", "reasoning"] {
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
@@ -12,15 +14,61 @@ pub(crate) fn extract_reasoning_field_text(value: &Value) -> Option<String> {
}
}
let reasoning = value.get("reasoning")?;
for key in ["content", "text", "summary"] {
if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) {
if let Some(reasoning) = value.get("reasoning") {
for key in ["content", "text", "summary"] {
if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) {
if !text.is_empty() {
return Some(text.to_string());
}
}
}
}
if let Some(details) = value.get("reasoning_details") {
if let Some(text) = extract_reasoning_details_text(details) {
return Some(text);
}
}
None
}
fn extract_reasoning_details_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => (!text.is_empty()).then(|| text.to_string()),
Value::Array(parts) => {
let text = parts
.iter()
.filter_map(extract_reasoning_detail_part_text)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
(!text.is_empty()).then_some(text)
}
Value::Object(_) => extract_reasoning_detail_part_text(value),
_ => None,
}
}
fn extract_reasoning_detail_part_text(value: &Value) -> Option<String> {
for key in ["text", "content", "summary"] {
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
if !text.is_empty() {
return Some(text.to_string());
}
}
}
if let Some(parts) = value.get("parts").and_then(|v| v.as_array()) {
let text = parts
.iter()
.filter_map(extract_reasoning_detail_part_text)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
return (!text.is_empty()).then_some(text);
}
None
}
+1 -1
View File
@@ -47,7 +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,
codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
@@ -8,6 +8,7 @@ use super::codex_chat_common::{
append_reasoning_content, extract_reasoning_field_text, extract_reasoning_summary_text,
response_function_call_item, split_leading_think_block,
};
use crate::provider::CodexChatReasoningConfig;
use crate::proxy::{
error::ProxyError,
json_canonical::{canonical_json_string, canonicalize_json_string_if_parseable},
@@ -31,7 +32,17 @@ const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[
"user",
];
/// Convert an OpenAI Responses request into an OpenAI Chat Completions request.
#[allow(dead_code)]
pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
responses_to_chat_completions_with_reasoning(body, None)
}
/// Convert an OpenAI Responses request into an OpenAI Chat Completions request,
/// using provider-declared Codex Chat reasoning capabilities when available.
pub fn responses_to_chat_completions_with_reasoning(
body: Value,
reasoning_config: Option<&CodexChatReasoningConfig>,
) -> Result<Value, ProxyError> {
let mut result = json!({});
if let Some(model) = body.get("model") {
@@ -76,11 +87,7 @@ pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
}
}
if super::transform::supports_reasoning_effort(model) {
if let Some(effort) = body.pointer("/reasoning/effort") {
result["reasoning_effort"] = effort.clone();
}
}
apply_reasoning_options(&mut result, &body, model, reasoning_config);
if let Some(tools) = body.get("tools").and_then(|v| v.as_array()) {
let tools: Vec<Value> = tools
@@ -125,6 +132,150 @@ pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
Ok(result)
}
fn apply_reasoning_options(
result: &mut Value,
body: &Value,
model: &str,
config: Option<&CodexChatReasoningConfig>,
) {
let Some(config) = config else {
if super::transform::supports_reasoning_effort(model) {
if let Some(effort) = body.pointer("/reasoning/effort") {
result["reasoning_effort"] = effort.clone();
}
}
return;
};
let supports_effort = config.supports_effort.unwrap_or(false);
let supports_thinking = config.supports_thinking.unwrap_or(false) || supports_effort;
let Some(reasoning_enabled) = reasoning_requested(body) else {
return;
};
if supports_thinking {
match config
.thinking_param
.as_deref()
.unwrap_or("thinking")
.trim()
.to_ascii_lowercase()
.as_str()
{
"thinking" => {
result["thinking"] = json!({
"type": if reasoning_enabled { "enabled" } else { "disabled" }
});
}
"enable_thinking" => {
result["enable_thinking"] = json!(reasoning_enabled);
}
"reasoning_split" => {
result["reasoning_split"] = json!(reasoning_enabled);
}
_ => {}
}
}
// effort_param 在 early return 之前算出:reasoning.effort 形态的「显式关闭」分支要用到。
let effort_param = config
.effort_param
.as_deref()
.unwrap_or("reasoning_effort")
.trim()
.to_ascii_lowercase();
if !reasoning_enabled {
// OpenRouter 原生 reasoning.effort 支持显式 "none"(语义:彻底关闭推理)。
// 上游显式发 effort=none/off/disabled(或 reasoning=null)时 reasoning_enabled 为 false
// 直接 return 会丢失关闭意图——OpenRouter 部分模型默认开思考,不带字段无法关闭,
// 造成行为与成本偏差;故对该形态忠实转发 {"reasoning":{"effort":"none"}}。
// 顶层 reasoning_effort 平台的枚举不含 none,仍走上方 thinking 关闭路径、不发 effort。
// 注意:完全不带 reasoning 字段时 reasoning_requested 返回 None 已提前 return
// 不会走到这里,故只有上游「显式」表达关闭才透传 none。
if effort_param == "reasoning.effort" {
result["reasoning"] = json!({ "effort": "none" });
}
return;
}
if !supports_effort {
return;
}
let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) else {
return;
};
let Some(mapped) = map_reasoning_effort(effort, config.effort_value_mode.as_deref()) else {
return;
};
match effort_param.as_str() {
// OpenAI 风格顶层字段(DeepSeek 官方、OpenAI o-series 等)。
"reasoning_effort" => {
result["reasoning_effort"] = json!(mapped);
}
// OpenRouter 原生归一化对象:reasoning.effort 会被 OpenRouter 翻译成各底层模型
// OpenAI/Grok/Gemini/Anthropic)的正确推理参数,覆盖面比顶层 OpenAI 别名更全。
// 本转换从空对象构造、不残留原始 reasoning 对象,故不会出现 reasoning 与
// reasoning_effort 并存触发 400 的情况(参见 openclaw#24119)。
"reasoning.effort" => {
result["reasoning"] = json!({ "effort": mapped });
}
_ => {}
}
}
fn reasoning_requested(body: &Value) -> Option<bool> {
if let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) {
return Some(!matches!(
effort.trim().to_ascii_lowercase().as_str(),
"none" | "off" | "disabled"
));
}
body.get("reasoning").map(|value| !value.is_null())
}
fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str> {
let effort = effort.trim().to_ascii_lowercase();
if matches!(effort.as_str(), "none" | "off" | "disabled") {
return None;
}
match mode.unwrap_or("passthrough") {
"deepseek" => match effort.as_str() {
"max" | "xhigh" => Some("max"),
_ => Some("high"),
},
"low_high" => match effort.as_str() {
"minimal" | "low" => Some("low"),
_ => Some("high"),
},
// OpenRouter effort 枚举为 xhigh|high|medium|low|minimal(无 max)。max 是
// Codex / 部分模型的扩展档位,对 OpenRouter 非法,会触发
// `400 reasoning_effort: Invalid option`(见 openclaw#77350);钳到最高合法档
// xhigh,其余合法值透传,未知值丢弃以免被上游拒绝。
"openrouter" => match effort.as_str() {
"max" | "xhigh" => Some("xhigh"),
"high" => Some("high"),
"medium" => Some("medium"),
"low" => Some("low"),
"minimal" => Some("minimal"),
_ => None,
},
_ => match effort.as_str() {
"minimal" => Some("minimal"),
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" => Some("xhigh"),
"max" => Some("max"),
_ => None,
},
}
}
/// MiniMax 严格要求 messages 中只能首条出现 `role=system`
/// 否则返回 `invalid params, chat content has invalid message role: system (2013)`。
/// 把所有 system 消息合并到首位,避免中间 system(如 Codex 的 `developer` 指令)触发该约束;
@@ -1107,6 +1258,195 @@ mod tests {
assert_eq!(result["reasoning_effort"], "high");
}
#[test]
fn responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model() {
let input = json!({
"model": "deepseek-v4-pro",
"input": "hello",
"reasoning": {"effort": "xhigh"}
});
let config = CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(true),
thinking_param: Some("thinking".to_string()),
effort_param: Some("reasoning_effort".to_string()),
effort_value_mode: Some("deepseek".to_string()),
output_format: Some("reasoning_content".to_string()),
};
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["reasoning_effort"], "max");
}
#[test]
fn responses_request_to_chat_maps_openrouter_to_native_reasoning_object() {
// OpenRouter 平台形态:原生 reasoning:{effort} 对象 + "openrouter" 值映射
// (与 infer_aggregator_platform_config 推断出的配置保持一致)。
let config = CodexChatReasoningConfig {
supports_thinking: Some(false),
supports_effort: Some(true),
thinking_param: Some("none".to_string()),
effort_param: Some("reasoning.effort".to_string()),
effort_value_mode: Some("openrouter".to_string()),
output_format: Some("auto".to_string()),
};
// max 不在 OpenRouter 枚举内(见 openclaw#77350),必须钳成 xhigh
// 且写进原生 reasoning 对象,而非顶层 reasoning_effort 别名。
let input = json!({
"model": "deepseek/deepseek-chat-v3.1",
"input": "hello",
"reasoning": {"effort": "max"}
});
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
assert!(result.get("reasoning_effort").is_none());
// thinking_param=none:即使 supports_effort 把 supports_thinking 带成 true
// 也不写任何 thinking 字段(OpenRouter 不认 thinking:{type})。
assert!(result.get("thinking").is_none());
// 合法档位原样透传。
let input_high = json!({
"model": "deepseek/deepseek-chat-v3.1",
"input": "hello",
"reasoning": {"effort": "high"}
});
let result_high =
responses_to_chat_completions_with_reasoning(input_high, Some(&config)).unwrap();
assert_eq!(result_high["reasoning"]["effort"], "high");
assert!(result_high.get("reasoning_effort").is_none());
}
#[test]
fn responses_request_to_chat_passes_explicit_none_through_for_openrouter() {
// OpenRouter 原生 reasoning 对象支持显式关闭:effort=none 应忠实转发为
// {"reasoning":{"effort":"none"}},而非被吞掉——否则默认开思考的模型无法关闭,
// 带来行为与成本偏差。
let config = CodexChatReasoningConfig {
supports_thinking: Some(false),
supports_effort: Some(true),
thinking_param: Some("none".to_string()),
effort_param: Some("reasoning.effort".to_string()),
effort_value_mode: Some("openrouter".to_string()),
output_format: Some("auto".to_string()),
};
let input = json!({
"model": "openai/gpt-5",
"input": "hello",
"reasoning": {"effort": "none"}
});
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
assert_eq!(result["reasoning"]["effort"], "none");
// none 不是 OpenAI 顶层 reasoning_effort 的合法枚举,不写顶层别名;也不写 thinking。
assert!(result.get("reasoning_effort").is_none());
assert!(result.get("thinking").is_none());
}
#[test]
fn responses_request_to_chat_drops_explicit_none_for_top_level_effort_provider() {
// 对照:顶层 reasoning_effort 平台(DeepSeek/OpenAI 风格)的 effort 枚举不含 none
// 显式 none 不应透传成 reasoning_effort:"none"(会被上游拒),仅走 thinking 关闭路径。
// 锁定「none 透传仅限 reasoning.effort 形态」的边界,防止回归。
let config = CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(true),
thinking_param: Some("thinking".to_string()),
effort_param: Some("reasoning_effort".to_string()),
effort_value_mode: Some("deepseek".to_string()),
output_format: Some("reasoning_content".to_string()),
};
let input = json!({
"model": "deepseek-v4-pro",
"input": "hello",
"reasoning": {"effort": "none"}
});
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
// thinking 关闭信号照发;但不写 reasoning_effort,也不写原生 reasoning 对象。
assert_eq!(result["thinking"]["type"], "disabled");
assert!(result.get("reasoning_effort").is_none());
assert!(result.get("reasoning").is_none());
}
#[test]
fn responses_request_to_chat_maps_thinking_only_provider_without_effort() {
let input = json!({
"model": "kimi-k2.6",
"input": "hello",
"reasoning": {"effort": "high"}
});
let config = CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
};
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn responses_request_to_chat_maps_enable_thinking_provider() {
let input = json!({
"model": "qwen3-max",
"input": "hello",
"reasoning": {"effort": "medium"}
});
let config = CodexChatReasoningConfig {
supports_thinking: Some(true),
supports_effort: Some(false),
thinking_param: Some("enable_thinking".to_string()),
effort_param: Some("none".to_string()),
effort_value_mode: None,
output_format: Some("reasoning_content".to_string()),
};
let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap();
assert_eq!(result["enable_thinking"], true);
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn chat_response_to_responses_extracts_reasoning_details() {
let input = json!({
"id": "chatcmpl_minimax",
"object": "chat.completion",
"created": 123,
"model": "MiniMax-M2.7",
"choices": [{
"message": {
"role": "assistant",
"reasoning_details": [
{"type": "reasoning_text", "text": "Need to inspect the code."}
],
"content": "Done"
},
"finish_reason": "stop"
}]
});
let result = chat_completion_to_response(input).unwrap();
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(
result["output"][0]["summary"][0]["text"],
"Need to inspect the code."
);
assert_eq!(result["output"][1]["content"][0]["text"], "Done");
}
#[test]
fn responses_request_to_chat_normalizes_codex_internal_roles() {
let input = json!({