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

### Fixed

- **Encrypted-only reasoning items open no summary part** — the Responses
stream encoder opened a `reasoning_summary_part` for every reasoning item and
closed it only when text had streamed, so an encrypted-only item left a part
open with no `done`. The part now opens on the first text delta. (#671)
- **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
Expand Down
25 changes: 23 additions & 2 deletions crates/switchyard-translation/src/codecs/responses/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -823,10 +823,27 @@ fn ensure_responses_reasoning_started(
"summary": [],
},
}));
out
}

// Opens the item's single summary part the first time text streams for it. Encrypted-only
// reasoning never streams text, so it never opens a part; the item closes with an empty
// `summary`, matching what `finish_responses_stream` emits for it.
fn ensure_responses_reasoning_summary_started(
state: &mut StreamTranslationState,
index: usize,
) -> Vec<Value> {
let mut out = ensure_responses_reasoning_started(state, index);
let item_id = responses_reasoning_item_id(state, index);
let item = state.response_reasoning.entry(index).or_default();
if item.summary_started {
return out;
}
item.summary_started = true;
out.push(json!({
"type": "response.reasoning_summary_part.added",
"item_id": item_id,
"output_index": output_index,
"output_index": item.output_index.unwrap_or(0),
"summary_index": 0,
"part": {"type": "summary_text", "text": ""},
}));
Expand All @@ -839,7 +856,11 @@ fn encode_responses_reasoning_delta(
index: usize,
text: String,
) -> Vec<Value> {
let mut out = ensure_responses_reasoning_started(state, index);
// An empty delta carries nothing to show and must not open a part that would never close.
if text.is_empty() {
return ensure_responses_reasoning_started(state, index);
}
let mut out = ensure_responses_reasoning_summary_started(state, index);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let item = state.response_reasoning.entry(index).or_default();
item.text.push_str(&text);
let output_index = item.output_index.unwrap_or(0);
Expand Down
5 changes: 4 additions & 1 deletion crates/switchyard-translation/src/codecs/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,11 @@ pub struct StreamTranslationState {
// One Responses reasoning output item under construction by the encoder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct ResponseReasoningState {
/// Set once the item's `added` events were emitted.
/// Set once the item's `added` event was emitted.
pub(crate) started: bool,
/// Set once the item's summary part opened, which happens on the first text delta. An
/// encrypted-only item never opens one, so it never has to close one either.
pub(crate) summary_started: bool,
pub(crate) output_index: Option<usize>,
/// Provider item id the encrypted reasoning was issued under. Used as the emitted item id
/// so the client's replay verifies upstream; `None` falls back to a synthesized id.
Expand Down
93 changes: 93 additions & 0 deletions crates/switchyard-translation/tests/stream_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2194,3 +2194,96 @@ fn responses_stream_opens_reasoning_under_provider_id_before_payload_arrives() -
assert_eq!(done["item"]["summary"][0]["text"], "plan");
Ok(())
}

// An encrypted-only reasoning item streams no text, so it must not open a summary part it can
// never close; the client should see the item open, then close with an empty `summary` and the
// encrypted payload attached.
#[test]
fn responses_stream_encrypted_only_reasoning_opens_no_summary_part() -> TestResult {
let engine = TranslationEngine::default();
let format = WireFormat::OpenAiResponses;
let mut state = StreamTranslationState::new(format, format);
let chunks = vec![
LlmResponseChunk::MessageStart {
id: Some("resp_1".into()),
model: Some(REASONING_MODEL.into()),
},
LlmResponseChunk::ReasoningDetailsDelta {
index: 0,
details: vec![json!({"type": "reasoning.encrypted", "data": "opaque", "id": "rs_1"})],
text: String::new(),
},
LlmResponseChunk::TextDelta {
index: 1,
text: "done".into(),
},
LlmResponseChunk::MessageStop { reason: None },
];
let mut events = Vec::new();
for chunk in chunks {
events.extend(engine.encode_stream_event(
&mut state,
format,
LlmResponseStreamEvent::new(vec![chunk]),
)?);
}
events.extend(engine.finish_stream(&mut state, format)?);
let types: Vec<&str> = events.iter().filter_map(|e| e["type"].as_str()).collect();

assert!(
!types
.iter()
.any(|t| t.starts_with("response.reasoning_summary")),
"{types:?}"
);
let done = events
.iter()
.find(|e| e["type"] == "response.output_item.done" && e["item"]["type"] == "reasoning")
.ok_or("expected the reasoning item to close")?;
assert_eq!(done["item"]["id"], "rs_1");
assert_eq!(done["item"]["encrypted_content"], "opaque");
assert_eq!(done["item"]["summary"], json!([]));
Ok(())
}

// An empty reasoning delta opens the item but not a summary part, so a provider that sends a
// blank first delta does not leave the client with an unclosed part.
#[test]
fn responses_stream_empty_reasoning_delta_opens_no_summary_part() -> TestResult {
let engine = TranslationEngine::default();
let format = WireFormat::OpenAiResponses;
let mut state = StreamTranslationState::new(format, format);
let chunks = vec![
LlmResponseChunk::MessageStart {
id: Some("resp_1".into()),
model: Some(REASONING_MODEL.into()),
},
LlmResponseChunk::ReasoningDelta {
index: 0,
text: String::new(),
},
LlmResponseChunk::TextDelta {
index: 1,
text: "done".into(),
},
LlmResponseChunk::MessageStop { reason: None },
];
let mut events = Vec::new();
for chunk in chunks {
events.extend(engine.encode_stream_event(
&mut state,
format,
LlmResponseStreamEvent::new(vec![chunk]),
)?);
}
events.extend(engine.finish_stream(&mut state, format)?);
let types: Vec<&str> = events.iter().filter_map(|e| e["type"].as_str()).collect();
assert!(
!types
.iter()
.any(|t| t.starts_with("response.reasoning_summary")),
"{types:?}"
);
assert!(types.contains(&"response.output_item.added"), "{types:?}");
Ok(())
}
Loading