Files
CC-Switch/src-tauri/src/proxy/providers/codex_chat_common.rs
T
Jason 5968336364 Restore Codex tool plugins over Chat Completions third-party proxy
When proxying Codex Responses requests through the Chat Completions
format (api_format=openai_chat), the request transform only forwarded
plain `function` tools and silently dropped `tool_search`, `namespace`
(MCP), and `custom` tools, so third-party APIs never saw the official
Codex plugins and could not call them.

Introduce CodexToolContext, built from the original Responses request,
which flattens all four tool kinds into Chat functions and keeps a
chat_name -> CodexToolSpec map. The response path (streaming and
non-streaming) looks names up in this map to restore the original
tool_search_call / custom_tool_call / namespaced function_call items
instead of reparsing the flattened name. Long namespaced names are
truncated with a sha256 suffix to fit the 64-char Chat tool-name limit.

Covered by new round-trip tests for all four tool kinds across both
streaming and non-streaming paths.
2026-06-01 16:29:23 +08:00

240 lines
7.0 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_function_call_item_with_namespace(
item_id: &str,
status: &str,
call_id: &str,
name: &str,
namespace: Option<&str>,
arguments: &str,
reasoning: Option<&str>,
) -> Value {
let mut item =
response_function_call_item(item_id, status, call_id, name, arguments, reasoning);
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
if let Some(obj) = item.as_object_mut() {
obj.insert("namespace".to_string(), json!(namespace));
}
}
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', ' '])
}