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
@@ -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
}