Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

34 changes: 32 additions & 2 deletions crates/protocol/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,29 @@ impl LlmResponse {
}
}

/// An encrypted reasoning detail that names its provider item id but carries no payload yet.
fn is_reasoning_id_announcement(detail: &Value) -> bool {
detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
&& detail.get("data").is_none()
}

/// Appends a reasoning detail to an accumulated block. A Responses stream decoder announces a
/// reasoning item's provider id (`{"type": "reasoning.encrypted", "id"}`) before the payload
/// arrives; the payload detail then replaces that announcement so history holds one detail.
fn push_reasoning_detail(details: &mut Vec<Value>, detail: Value) {
if detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
&& let Some(id) = detail.get("id").and_then(Value::as_str)
&& let Some(announcement) = details.iter_mut().find(|existing| {
is_reasoning_id_announcement(existing)
&& existing.get("id").and_then(Value::as_str) == Some(id)
})
{
*announcement = detail;
return;
}
details.push(detail);
}

impl AggLlmResponse {
/// Converts a fully-buffered response into a synthetic chunk stream.
///
Expand Down Expand Up @@ -393,7 +416,9 @@ impl ResponseAccumulator {
.push_str(&text);
}
LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
self.reasoning_details.extend(details);
for detail in details {
push_reasoning_detail(&mut self.reasoning_details, detail);
}
if !text.is_empty() {
self.reasoning
.get_or_insert_with(String::new)
Expand Down Expand Up @@ -433,7 +458,12 @@ impl ResponseAccumulator {
content.push(ContentBlock::Reasoning {
text: self.reasoning.unwrap_or_default(),
signature: None,
details: self.reasoning_details,
// An announcement whose payload never arrived is a stream-level hint only.
details: self
.reasoning_details
.into_iter()
.filter(|detail| !is_reasoning_id_announcement(detail))
.collect(),
});
}
if !self.text.is_empty() {
Expand Down
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
93 changes: 93 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,68 @@ 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 encrypted reasoning detail, if any.
///
/// Two detail shapes are accepted: the documented `{"type": "reasoning.encrypted", "data"}`
/// object, and a verbatim Responses `reasoning` item carrying `encrypted_content` (the shape
/// the buffered request decoder stores when it keeps the provider item whole).
pub(crate) fn encrypted_reasoning_data(details: &[Value]) -> Option<String> {
details
.iter()
.filter_map(Value::as_object)
.find_map(|detail| match detail.get("type").and_then(Value::as_str) {
Some("reasoning.encrypted") => detail.get("data").and_then(Value::as_str),
Some("reasoning") => detail.get("encrypted_content").and_then(Value::as_str),
_ => None,
})
.filter(|data| !data.is_empty())
.map(ToOwned::to_owned)
}

/// Returns the provider item id recorded on the first `reasoning.encrypted` detail, if any.
/// Encrypted reasoning is bound to the item id it was issued under, so a replay must reuse it.
pub(crate) fn encrypted_reasoning_item_id(details: &[Value]) -> Option<String> {
details
.iter()
.filter_map(Value::as_object)
.find(|detail| {
matches!(
detail.get("type").and_then(Value::as_str),
Some("reasoning.encrypted" | "reasoning")
)
})
.and_then(|detail| detail.get("id").and_then(Value::as_str))
.filter(|id| !id.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 All @@ -88,3 +150,34 @@ pub(crate) fn provider_extensions(
}
extensions
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn encrypted_reasoning_helpers_accept_both_detail_shapes() {
let documented = vec![json!({"type": "reasoning.encrypted", "data": "blob", "id": "rs_1"})];
assert_eq!(
encrypted_reasoning_data(&documented).as_deref(),
Some("blob")
);
assert_eq!(
encrypted_reasoning_item_id(&documented).as_deref(),
Some("rs_1")
);
let verbatim_item = vec![json!({
"type": "reasoning", "id": "rs_2", "summary": [], "encrypted_content": "blob2"
})];
assert_eq!(
encrypted_reasoning_data(&verbatim_item).as_deref(),
Some("blob2")
);
assert_eq!(
encrypted_reasoning_item_id(&verbatim_item).as_deref(),
Some("rs_2")
);
assert_eq!(encrypted_reasoning_data(&[json!({"type": "other"})]), None);
}
}
12 changes: 12 additions & 0 deletions crates/switchyard-translation/src/codecs/openai_chat/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,18 @@ fn encode_openai_chat_stream(
)]
}
LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
// A Responses decoder announces a reasoning item's provider id ahead of its
// payload; that announcement carries nothing a chat client can use.
let details: Vec<Value> = details
.into_iter()
.filter(|detail| {
detail.get("type").and_then(Value::as_str) != Some("reasoning.encrypted")
|| detail.get("data").is_some()
})
.collect();
if details.is_empty() && text.is_empty() {
return Vec::new();
}
let details_text = reasoning_text_from_details(&details);
let mut delta = json!({"reasoning_details": details});
if !text.is_empty() && details_text.as_deref() != Some(text.as_str()) {
Expand Down
92 changes: 55 additions & 37 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::collections::HashSet;
use serde_json::{Map, Value, json};

use crate::codecs::common::{
collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id,
is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks,
};
use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
Expand Down Expand Up @@ -653,38 +654,32 @@ 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| {
// The payload only verifies under the id it was issued with, so carry that id.
let mut detail = json!({"type": "reasoning.encrypted", "data": data});
if let Some(id) = item
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
{
detail["id"] = Value::String(id.to_string());
}
vec![detail]
})
.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 +1337,20 @@ 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,
});
let encrypted_reasoning_id = output.content.iter().find_map(|block| match block {
ContentBlock::Reasoning { details, .. } => encrypted_reasoning_item_id(details),
_ => None,
});
if !reasoning.is_empty() || encrypted_reasoning.is_some() {
items.push(encode_responses_reasoning_output(
&reasoning,
encrypted_reasoning.as_deref(),
encrypted_reasoning_id.as_deref(),
));
}

if !text.is_empty() || (!has_tool_calls && reasoning.is_empty()) {
Expand Down Expand Up @@ -1376,18 +1383,29 @@ 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>,
item_id: 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}));
}
// Encrypted reasoning verifies only under the id it was issued with, so reuse it.
let mut item = json!({
"type": "reasoning",
"id": "rs_switchyard",
"id": item_id.unwrap_or("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