Skip to content
Draft
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/switchyard-translation/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ keywords = ["llm", "translation", "openai", "anthropic"]
publish = ["crates-io"]

[dependencies]
tracing.workspace = true
base64.workspace = true
serde.workspace = true
serde_json.workspace = true
Expand Down
38 changes: 38 additions & 0 deletions crates/switchyard-translation/src/codecs/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,44 @@ pub(crate) fn reasoning_text_from_details(details: &[Value]) -> Option<String> {
(!parts.is_empty()).then(|| parts.join("\n"))
}

/// Collects reasoning text from a Responses reasoning item's `content` or `summary`
/// array, or from a bare string, into `out`. Empty strings are skipped.
pub(crate) fn collect_responses_reasoning_text(value: Option<&Value>, out: &mut Vec<String>) {
match value {
Some(Value::String(text)) if !text.is_empty() => out.push(text.clone()),
Some(Value::Array(items)) => {
for item in items {
match item {
Value::String(text) if !text.is_empty() => out.push(text.clone()),
Value::Object(object) => {
if matches!(
object.get("type").and_then(Value::as_str),
Some("reasoning_text" | "summary_text" | "text")
) && let Some(text) = object.get("text").and_then(Value::as_str)
&& !text.is_empty()
{
out.push(text.to_string());
}
}
_ => {}
}
}
}
_ => {}
}
}

/// Returns the opaque payload of the first `reasoning.encrypted` detail, if any.
pub(crate) fn encrypted_reasoning_data(details: &[Value]) -> Option<String> {
details
.iter()
.filter_map(Value::as_object)
.find(|detail| detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted"))
.and_then(|detail| detail.get("data").and_then(Value::as_str))
.filter(|data| !data.is_empty())
.map(ToOwned::to_owned)
}

/// Returns the first non-empty string stored under the requested keys.
pub(crate) fn first_nonempty_string<'a>(
object: &'a Map<String, Value>,
Expand Down
71 changes: 34 additions & 37 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use std::collections::HashSet;
use serde_json::{Map, Value, json};

use crate::codecs::common::{
is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks,
collect_responses_reasoning_text, encrypted_reasoning_data, is_known_role_name,
provider_extensions, reasoning_text_from_blocks, text_from_blocks,
};
use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
use crate::codecs::{
Expand Down Expand Up @@ -653,38 +654,21 @@ fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBloc
if let Some(text) = item.get("text").and_then(Value::as_str) {
parts.push(text.to_string());
}
// Keep the opaque payload so an encrypted-only item survives a decode/encode round trip.
let details = item
.get("encrypted_content")
.and_then(Value::as_str)
.filter(|data| !data.is_empty())
.map(|data| vec![json!({"type": "reasoning.encrypted", "data": data})])
.unwrap_or_default();
vec![ContentBlock::Reasoning {
text: parts.join("\n"),
signature: None,
details: Vec::new(),
details,
}]
}

// Collects text from the known Responses reasoning content/summary shapes.
fn collect_responses_reasoning_text(value: Option<&Value>, out: &mut Vec<String>) {
match value {
Some(Value::String(text)) if !text.is_empty() => out.push(text.clone()),
Some(Value::Array(items)) => {
for item in items {
match item {
Value::String(text) if !text.is_empty() => out.push(text.clone()),
Value::Object(object) => {
if matches!(
object.get("type").and_then(Value::as_str),
Some("reasoning_text" | "summary_text" | "text")
) && let Some(text) = object.get("text").and_then(Value::as_str)
&& !text.is_empty()
{
out.push(text.to_string());
}
}
_ => {}
}
}
}
_ => {}
}
}

// Decodes Responses content arrays or strings into normalized content blocks.
fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
Expand Down Expand Up @@ -1342,8 +1326,15 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
};
let mut items = Vec::new();

if !reasoning.is_empty() {
items.push(encode_responses_reasoning_output(&reasoning));
let encrypted_reasoning = output.content.iter().find_map(|block| match block {
ContentBlock::Reasoning { details, .. } => encrypted_reasoning_data(details),
_ => None,
});
if !reasoning.is_empty() || encrypted_reasoning.is_some() {
items.push(encode_responses_reasoning_output(
&reasoning,
encrypted_reasoning.as_deref(),
));
}

if !text.is_empty() || (!has_tool_calls && reasoning.is_empty()) {
Expand Down Expand Up @@ -1376,18 +1367,24 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
)
}

// Encodes private reasoning as a separate Responses output item.
fn encode_responses_reasoning_output(text: &str) -> Value {
json!({
// Encodes private reasoning as a separate Responses output item. An encrypted-only item
// carries no text part but keeps `encrypted_content` so the client can replay it.
fn encode_responses_reasoning_output(text: &str, encrypted: Option<&str>) -> Value {
// Standard Responses shape: text as `summary_text` parts, which is what clients record.
let mut summary = Vec::new();
if !text.is_empty() {
summary.push(json!({"type": "summary_text", "text": text}));
}
let mut item = json!({
"type": "reasoning",
"id": "rs_switchyard",
"status": "completed",
"content": [{
"type": "reasoning_text",
"text": text,
}],
"summary": [],
})
"summary": summary,
});
if let Some(encrypted) = encrypted {
item["encrypted_content"] = Value::String(encrypted.to_string());
}
item
}

// Serializes JSON with Python-like spacing to match legacy converter behavior.
Expand Down
Loading
Loading