Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Raw Responses stream trace** — an opt-in trace of every upstream Responses
event as received, under `RUST_LOG=switchyard_translation::responses::raw=trace`,
for diagnosing provider-specific event shapes. (#646)
- **NeMo Relay native plugin** — a dynamically loaded integration that loads
Switchyard's standard TOML deployment and executes its `switchyard-runner`-
supported configured routes in process. Managed calls require NeMo Relay
Expand Down Expand Up @@ -99,6 +102,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **Responses reasoning through transforming routes** — reasoning that a route
buffers or re-encodes now reaches the client in the standard `summary_text`
shape with `reasoning_summary_*` events, encrypted-only and done-only
reasoning items are decoded from every carrier a provider uses, and encrypted
payloads are re-emitted under the provider's item id so the client's replay
verifies upstream. Responses with several reasoning items keep all of them.
(#646)
- **Unique, bounded Responses item ids** — synthesized output-item ids carry a
per-response discriminator so replayed history no longer repeats `rs_0` and
`fc_1` across turns, and upstream response ids longer than 40 characters are
digested to stay within OpenAI's 64-character item-id limit. (#646)
- **Reasoning order in mixed stream chunks** — the OpenAI Chat stream decoder
emits reasoning deltas before content deltas from the same chunk, so
interleaved reasoning is no longer reordered. (#387)
Expand Down
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 @@ -24,6 +24,7 @@ switchyard-protocol.workspace = true
thiserror.workspace = true
futures.workspace = true
async-stream.workspace = true
tracing.workspace = true

[dev-dependencies]
pretty_assertions = "1"
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
74 changes: 36 additions & 38 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 @@ -667,32 +668,6 @@ fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBloc
}]
}

// 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> {
match value {
Expand Down Expand Up @@ -1379,8 +1354,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 @@ -1413,18 +1400,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