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 crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ async fn affinity_warns_once_when_request_has_no_usable_identity() -> switchyard
content: vec![ContentBlock::Reasoning {
text: "provider reasoning".to_string(),
signature: None,
details: Vec::new(),
}],
}],
..LlmRequest::default()
Expand Down
1 change: 1 addition & 0 deletions crates/libsy/src/algorithms/util/affinity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ mod tests {
ContentBlock::Reasoning {
text: "Internal provider reasoning.".to_string(),
signature: Some("provider-signature".to_string()),
details: Vec::new(),
},
],
});
Expand Down
1 change: 1 addition & 0 deletions crates/libsy/src/algorithms/util/llm_judge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ mod tests {
ContentBlock::Reasoning {
text: r#"{"ok":false}"#.to_string(),
signature: None,
details: Vec::new(),
},
);
}
Expand Down
4 changes: 4 additions & 0 deletions crates/protocol/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ pub enum ContentBlock {
text: String,
/// Provider signature used to validate or continue the reasoning block.
signature: Option<String>,
/// Structured reasoning details, such as an encrypted `{ "type":
/// "reasoning.encrypted", "data": "..." }` object, replayed without modification.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
details: Vec<Value>,
Comment thread
grahamking marked this conversation as resolved.
},
/// Image content.
Image {
Expand Down
75 changes: 68 additions & 7 deletions crates/protocol/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,19 @@ impl AggLlmResponse {
text,
});
}
ContentBlock::Reasoning { text, .. } => {
chunks.push(LlmResponseChunk::ReasoningDelta {
index: output_index,
text,
});
ContentBlock::Reasoning { text, details, .. } => {
if !details.is_empty() {
chunks.push(LlmResponseChunk::ReasoningDetailsDelta {
index: output_index,
details,
text,
});
} else {
chunks.push(LlmResponseChunk::ReasoningDelta {
index: output_index,
text,
});
}
}
ContentBlock::ToolCall(tool) => {
let args = serde_json::to_string(&tool.arguments).unwrap_or_default();
Expand Down Expand Up @@ -272,6 +280,15 @@ pub enum LlmResponseChunk {
/// Reasoning fragment.
text: String,
},
/// Adds structured reasoning details to one output index.
ReasoningDetailsDelta {
/// Provider output index.
index: usize,
/// Reasoning detail objects in provider order.
details: Vec<Value>,
/// Normalized reasoning text represented by or accompanying the details.
text: String,
},
/// Adds or updates a tool call at one index.
ToolCallDelta {
/// Tool-call index within the response.
Expand Down Expand Up @@ -322,6 +339,7 @@ pub struct ResponseAccumulator {
model: Option<String>,
text: String,
reasoning: Option<String>,
reasoning_details: Vec<Value>,
tool_calls: BTreeMap<usize, PartialToolCall>,
usage: Usage,
stop_reason: Option<StopReason>,
Expand Down Expand Up @@ -359,6 +377,14 @@ impl ResponseAccumulator {
.get_or_insert_with(String::new)
.push_str(&text);
}
LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
self.reasoning_details.extend(details);
if !text.is_empty() {
self.reasoning
.get_or_insert_with(String::new)
.push_str(&text);
}
}
LlmResponseChunk::ToolCallDelta {
index,
id,
Expand Down Expand Up @@ -388,10 +414,11 @@ impl ResponseAccumulator {
/// tool calls (by ascending delta index) — a single assistant output.
pub fn finish(self) -> AggLlmResponse {
let mut content = Vec::new();
if let Some(reasoning) = self.reasoning {
if self.reasoning.is_some() || !self.reasoning_details.is_empty() {
content.push(ContentBlock::Reasoning {
text: reasoning,
text: self.reasoning.unwrap_or_default(),
signature: None,
details: self.reasoning_details,
});
}
if !self.text.is_empty() {
Expand Down Expand Up @@ -611,6 +638,7 @@ mod tests {
ContentBlock::Reasoning {
text: "think".to_string(),
signature: None,
details: Vec::new(),
},
ContentBlock::Text {
text: "answer".to_string(),
Expand Down Expand Up @@ -649,6 +677,39 @@ mod tests {
assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
}

#[test]
fn into_stream_retains_encrypted_reasoning_and_text() {
let details = vec![json!({
"type": "reasoning.encrypted",
"data": "opaque-encrypted-reasoning"
})];
let original = AggLlmResponse {
outputs: vec![ResponseOutput {
role: Role::Assistant,
content: vec![ContentBlock::Reasoning {
text: "fallback reasoning".to_string(),
signature: None,
details: details.clone(),
}],
stop_reason: Some(StopReason::EndTurn),
}],
..AggLlmResponse::default()
};

let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg())
.expect("into_agg failed");
let ContentBlock::Reasoning {
text,
details: recovered_details,
..
} = &recovered.outputs[0].content[0]
else {
panic!("expected reasoning block");
};
assert_eq!(text, "fallback reasoning");
assert_eq!(recovered_details, &details);
}

#[test]
fn into_agg_preserves_stream_item_error() {
let response = LlmResponse::Stream(Box::pin(stream::once(async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ fn decode_anthropic_content_block(
.and_then(Value::as_str)
.filter(|signature| !signature.is_empty())
.map(ToOwned::to_owned),
details: Vec::new(),
}],
Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall {
id: block
Expand Down Expand Up @@ -794,6 +795,7 @@ fn encode_one_anthropic_response_block(block: &ContentBlock) -> Vec<Value> {
ContentBlock::Reasoning {
text,
signature: None,
..
} => vec![json!({
"type": "thinking",
"thinking": text,
Expand All @@ -812,6 +814,7 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec<Value> {
ContentBlock::Reasoning {
text,
signature: Some(signature),
..
} if !signature.is_empty() => vec![json!({
"type": "thinking",
"thinking": text,
Expand Down
12 changes: 12 additions & 0 deletions crates/switchyard-translation/src/codecs/anthropic/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ fn encode_anthropic_stream(
}));
out
}
LlmResponseChunk::ReasoningDetailsDelta { text, .. } => {
if text.is_empty() {
return Vec::new();
}
let mut out = ensure_anthropic_reasoning_block(state);
out.push(json!({
"type": "content_block_delta",
"index": state.reasoning_block_index.unwrap_or(0),
"delta": {"type": "thinking_delta", "thinking": text},
}));
out
}
LlmResponseChunk::ToolCallDelta {
index,
id,
Expand Down
36 changes: 35 additions & 1 deletion crates/switchyard-translation/src/codecs/common.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Provider-agnostic helpers shared by buffered wire-format codecs.
//! Provider-agnostic helpers shared by wire-format codecs.

use serde_json::{Map, Value};

Expand Down Expand Up @@ -41,6 +41,40 @@ pub(crate) fn reasoning_text_from_blocks(content: &[ContentBlock], separator: &s
.join(separator)
}

/// Extracts displayable text from structured reasoning details.
pub(crate) fn reasoning_text_from_details(details: &[Value]) -> Option<String> {
Comment thread
grahamking marked this conversation as resolved.
let parts = details
.iter()
.filter_map(Value::as_object)
.filter_map(|detail| {
detail
.get("text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.or_else(|| {
detail
.get("summary")
.and_then(Value::as_str)
.filter(|summary| !summary.is_empty())
})
})
.collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.join("\n"))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Returns the first non-empty string stored under the requested keys.
pub(crate) fn first_nonempty_string<'a>(
object: &'a Map<String, Value>,
keys: &[&str],
) -> Option<&'a str> {
keys.iter().find_map(|key| {
object
.get(*key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
}

/// Copies unknown provider fields into the IR extension map.
pub(crate) fn provider_extensions(
object: &Map<String, Value>,
Expand Down
Loading