Files
CC-Switch/src-tauri/src/proxy/providers/codex_chat_common.rs
T
Jason 44d9aabbf3 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.
2026-05-25 22:20:33 +08:00

221 lines
6.5 KiB
Rust

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()) {
if !text.is_empty() {
return Some(text.to_string());
}
}
}
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
}
pub(crate) fn extract_reasoning_summary_text(value: &Value) -> Option<String> {
for key in ["reasoning_content", "content", "text"] {
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
if !text.is_empty() {
return Some(text.to_string());
}
}
}
let summary = value.get("summary")?;
if let Some(text) = summary.as_str() {
return (!text.is_empty()).then(|| text.to_string());
}
let parts = summary.as_array()?;
let text = parts
.iter()
.filter_map(|part| {
part.get("text")
.and_then(|v| v.as_str())
.or_else(|| part.get("content").and_then(|v| v.as_str()))
.or_else(|| part.as_str())
})
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
(!text.is_empty()).then_some(text)
}
pub(crate) fn append_reasoning_content(message: &mut Map<String, Value>, reasoning: &str) -> bool {
let reasoning = reasoning.trim();
if reasoning.is_empty() {
return false;
}
match message.get_mut("reasoning_content") {
Some(Value::String(existing)) if !existing.is_empty() => {
existing.push_str("\n\n");
existing.push_str(reasoning);
}
_ => {
message.insert(
"reasoning_content".to_string(),
Value::String(reasoning.to_string()),
);
}
}
true
}
pub(crate) fn attach_reasoning_content_field(item: &mut Value, reasoning: &str) -> bool {
let reasoning = reasoning.trim();
if reasoning.is_empty() {
return false;
}
if let Some(obj) = item.as_object_mut() {
obj.insert(
"reasoning_content".to_string(),
Value::String(reasoning.to_string()),
);
return true;
}
false
}
pub(crate) fn attach_optional_reasoning_content_field(
item: &mut Value,
reasoning: Option<&str>,
) -> bool {
let Some(reasoning) = reasoning else {
return false;
};
attach_reasoning_content_field(item, reasoning)
}
pub(crate) fn response_function_call_item(
item_id: &str,
status: &str,
call_id: &str,
name: &str,
arguments: &str,
reasoning: Option<&str>,
) -> Value {
let mut item = json!({
"id": item_id,
"type": "function_call",
"status": status,
"call_id": call_id,
"name": name,
"arguments": arguments
});
attach_optional_reasoning_content_field(&mut item, reasoning);
item
}
pub(crate) fn response_item_call_id(item: &Value) -> Option<String> {
item.get("call_id")
.or_else(|| item.get("id"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
pub(crate) fn is_empty_value(value: &Value) -> bool {
match value {
Value::Null => true,
Value::String(value) => value.trim().is_empty(),
Value::Array(value) => value.is_empty(),
Value::Object(value) => value.is_empty(),
_ => false,
}
}
pub(crate) fn split_leading_think_block(text: &str) -> Option<(String, String)> {
let leading_ws_len = text.len() - text.trim_start().len();
let after_ws = &text[leading_ws_len..];
if !after_ws.starts_with(THINK_OPEN_TAG) {
return None;
}
let body_start = leading_ws_len + THINK_OPEN_TAG.len();
let close_relative = text[body_start..].find(THINK_CLOSE_TAG)?;
let close_start = body_start + close_relative;
let answer_start = close_start + THINK_CLOSE_TAG.len();
Some((
text[body_start..close_start].trim().to_string(),
strip_think_answer_separator(&text[answer_start..]).to_string(),
))
}
pub(crate) fn strip_leading_think_open_tag(text: &str) -> Option<String> {
let leading_ws_len = text.len() - text.trim_start().len();
let after_ws = &text[leading_ws_len..];
after_ws
.strip_prefix(THINK_OPEN_TAG)
.map(|value| value.trim().to_string())
}
fn strip_think_answer_separator(text: &str) -> &str {
text.trim_start_matches(['\r', '\n', '\t', ' '])
}