mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 02:05:57 +08:00
Add Codex Chat-to-Responses bridge
- Add Codex provider API format selection and model mapping for Chat-only upstreams. - Convert Codex Responses requests to Chat Completions and rebuild Chat responses as Responses output. - Preserve reasoning_content across non-streaming, streaming, tool calls, and previous_response_id follow-ups. - Add a bounded Codex Chat history cache for restoring tool calls before tool outputs. - Cover Chat bridge, compact routing, streaming, and history recovery with focused tests.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
const THINK_OPEN_TAG: &str = "<think>";
|
||||
const THINK_CLOSE_TAG: &str = "</think>";
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 !text.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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', ' '])
|
||||
}
|
||||
Reference in New Issue
Block a user