Skip to content
Draft
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
Loading
Loading