Skip to content

Commit 6dfc98c

Browse files
fix(translation): preserve OpenAI Chat refusals
Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com>
1 parent cb630dd commit 6dfc98c

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

crates/switchyard-translation/src/codecs/openai_chat/buffered.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,11 @@ impl FormatCodec for OpenAiChatCodec {
284284
.and_then(Value::as_object)
285285
.cloned()
286286
.unwrap_or_default();
287+
let content_is_null = message.get("content").is_none_or(Value::is_null);
288+
let refusal = message
289+
.get("refusal")
290+
.and_then(Value::as_str)
291+
.filter(|text| !text.is_empty());
287292
let mut content = decode_openai_content(
288293
message.get("content").unwrap_or(&Value::Null),
289294
WireFormat::OpenAiChat,
@@ -292,6 +297,18 @@ impl FormatCodec for OpenAiChatCodec {
292297
"$.choices[0].message.content",
293298
)?;
294299
prepend_openai_reasoning_blocks(&mut content, &message);
300+
if let Some(text) = refusal {
301+
// Null content normally creates an empty placeholder, but the sibling refusal
302+
// field is the actual assistant content for a structured refusal.
303+
if content_is_null {
304+
content.retain(
305+
|block| !matches!(block, ContentBlock::Text { text } if text.is_empty()),
306+
);
307+
}
308+
content.push(ContentBlock::Refusal {
309+
text: text.to_string(),
310+
});
311+
}
295312
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
296313
for (index, tool_call) in tool_calls.iter().enumerate() {
297314
if let Some(call) = decode_openai_tool_call(
@@ -303,12 +320,16 @@ impl FormatCodec for OpenAiChatCodec {
303320
}
304321
}
305322
}
323+
let finish_reason = choice.get("finish_reason").and_then(Value::as_str);
324+
let stop_reason = if refusal.is_some() && matches!(finish_reason, Some("stop") | None) {
325+
StopReason::ContentFilter
326+
} else {
327+
map_openai_finish_reason(finish_reason)
328+
};
306329
response.outputs.push(ResponseOutput {
307330
role: Role::Assistant,
308331
content,
309-
stop_reason: Some(map_openai_finish_reason(
310-
choice.get("finish_reason").and_then(Value::as_str),
311-
)),
332+
stop_reason: Some(stop_reason),
312333
});
313334
}
314335

crates/switchyard-translation/src/codecs/openai_chat/stream.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ fn decode_openai_chat_stream(
134134
text: text.to_string(),
135135
});
136136
}
137+
if let Some(text) = delta.get("refusal").and_then(Value::as_str)
138+
&& !text.is_empty()
139+
{
140+
state.stop_reason = Some("content_filter".to_string());
141+
out.push(LlmResponseChunk::TextDelta {
142+
index: 0,
143+
text: text.to_string(),
144+
});
145+
}
137146
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
138147
for tool_call in tool_calls {
139148
if let Some(tool_call) = tool_call.as_object() {
@@ -159,6 +168,12 @@ fn decode_openai_chat_stream(
159168
}
160169
}
161170
if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
171+
let reason =
172+
if reason == "stop" && state.stop_reason.as_deref() == Some("content_filter") {
173+
"content_filter"
174+
} else {
175+
reason
176+
};
162177
out.push(LlmResponseChunk::MessageStop {
163178
reason: Some(reason.to_string()),
164179
});

0 commit comments

Comments
 (0)