mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
44d9aabbf3
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.
221 lines
6.5 KiB
Rust
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', ' '])
|
|
}
|