mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
5968336364
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.
240 lines
7.0 KiB
Rust
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', ' '])
|
|
}
|