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:
Jason
2026-05-19 19:06:00 +08:00
parent 5315fa284b
commit 74acf1e387
16 changed files with 1796 additions and 163 deletions
@@ -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', ' '])
}