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
11 changes: 10 additions & 1 deletion crates/protocol/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ fn stop_reason_from_str(reason: Option<&str>) -> StopReason {
match reason {
Some("length" | "max_tokens") => StopReason::MaxTokens,
Some("tool_calls" | "function_call" | "tool_use") => StopReason::ToolUse,
Some("content_filter") => StopReason::ContentFilter,
Some("content_filter" | "refusal") => StopReason::ContentFilter,
Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn,
Some(_) => StopReason::Unknown,
}
Expand Down Expand Up @@ -490,6 +490,15 @@ mod tests {
);
}

#[test]
fn folds_anthropic_refusal_as_content_filter() {
let agg = fold(vec![LlmResponseChunk::MessageStop {
reason: Some("refusal".to_string()),
}]);

assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ContentFilter));
}

#[test]
fn aggregates_normalized_chunks_inside_stream_event() {
let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
Expand Down
26 changes: 20 additions & 6 deletions crates/switchyard-translation/src/codecs/anthropic/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,17 +340,20 @@ impl FormatCodec for AnthropicMessagesCodec {
let content = output
.map(|output| encode_anthropic_content(&output.content))
.unwrap_or_else(|| vec![json!({"type": "text", "text": ""})]);
let normalized_stop_reason = output.and_then(|output| output.stop_reason);
let body = json!({
"id": response.id.clone().unwrap_or_else(|| "msg_switchyard".to_string()),
"type": "message",
"role": "assistant",
"model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
"content": content,
"stop_reason": output
.and_then(|output| output.stop_reason)
"stop_reason": normalized_stop_reason
.map(anthropic_stop_reason)
.unwrap_or("end_turn"),
"stop_sequence": Value::Null,
"stop_details": normalized_stop_reason
.map(anthropic_stop_details)
.unwrap_or(Value::Null),
"usage": encode_anthropic_usage(&response.usage),
});
Ok(EncodedResponse {
Expand Down Expand Up @@ -977,6 +980,7 @@ fn map_anthropic_stop_reason(reason: Option<&str>) -> StopReason {
match reason {
Some("max_tokens") => StopReason::MaxTokens,
Some("tool_use") => StopReason::ToolUse,
Some("refusal") => StopReason::ContentFilter,
Some("end_turn") | None => StopReason::EndTurn,
_ => StopReason::Unknown,
}
Expand All @@ -987,9 +991,19 @@ fn anthropic_stop_reason(reason: StopReason) -> &'static str {
match reason {
StopReason::MaxTokens => "max_tokens",
StopReason::ToolUse => "tool_use",
StopReason::EndTurn
| StopReason::ContentFilter
| StopReason::Error
| StopReason::Unknown => "end_turn",
StopReason::ContentFilter => "refusal",
StopReason::EndTurn | StopReason::Error | StopReason::Unknown => "end_turn",
}
}

// Emits the metadata object required by Anthropic refusal responses.
fn anthropic_stop_details(reason: StopReason) -> Value {
match reason {
StopReason::ContentFilter => json!({
"type": "refusal",
"category": Value::Null,
"explanation": Value::Null,
}),
_ => Value::Null,
}
}
22 changes: 19 additions & 3 deletions crates/switchyard-translation/src/codecs/anthropic/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ fn finish_anthropic_stream(state: &mut StreamTranslationState) -> Vec<Value> {
"delta": {
"stop_reason": anthropic_stop_reason(state.stop_reason.as_deref()),
"stop_sequence": Value::Null,
"stop_details": anthropic_stop_details(state.stop_reason.as_deref()),
},
"usage": anthropic_stream_usage(state),
}));
Expand Down Expand Up @@ -572,13 +573,28 @@ fn anthropic_stop_reason(reason: Option<&str>) -> String {
match reason {
Some("length") => "max_tokens".to_string(),
Some("tool_calls") | Some("function_call") => "tool_use".to_string(),
Some("end_turn") | Some("max_tokens") | Some("tool_use") | Some("stop_sequence") => {
reason.unwrap_or("end_turn").to_string()
}
Some("content_filter") => "refusal".to_string(),
Some("end_turn")
| Some("max_tokens")
| Some("tool_use")
| Some("stop_sequence")
| Some("refusal") => reason.unwrap_or("end_turn").to_string(),
_ => "end_turn".to_string(),
}
}

// Emits the metadata object required by Anthropic refusal events.
fn anthropic_stop_details(reason: Option<&str>) -> Value {
match reason {
Some("content_filter" | "refusal") => json!({
"type": "refusal",
"category": Value::Null,
"explanation": Value::Null,
}),
_ => Value::Null,
}
}

// Converts a streamed tool input fragment into a string delta.
fn tool_input_delta(value: &Value) -> Option<String> {
match value {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ fn openai_finish_reason(reason: Option<&str>) -> String {
Some("end_turn") | Some("stop_sequence") | None => "stop".to_string(),
Some("max_tokens") => "length".to_string(),
Some("tool_use") => "tool_calls".to_string(),
Some("refusal") => "content_filter".to_string(),
Some(other) => other.to_string(),
}
}
61 changes: 61 additions & 0 deletions crates/switchyard-translation/tests/response_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,3 +600,64 @@ fn incomplete_responses_source_survives_translation() -> TestResult {
assert_eq!(output["choices"][0]["finish_reason"], "length");
Ok(())
}

// Verifies a moderation stop reaches Anthropic clients as `refusal` rather than
// being reported as a normal `end_turn`.
#[test]
fn openai_content_filter_translates_to_anthropic_refusal() -> TestResult {
let engine = TranslationEngine::default();
let body = json!({
"id": "chatcmpl-test",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Partial answer"},
"finish_reason": "content_filter"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});

let output = engine
.translate_response(
WireFormat::OpenAiChat,
WireFormat::AnthropicMessages,
&body,
&TranslationPolicy::default(),
)?
.body;

assert_eq!(output["stop_reason"], "refusal");
assert_eq!(
output["stop_details"],
json!({"type": "refusal", "category": null, "explanation": null})
);
Ok(())
}

// Verifies the same distinction survives the other direction, so an Anthropic
// `refusal` is not flattened when re-encoded for an OpenAI client.
#[test]
fn anthropic_refusal_translates_to_openai_content_filter() -> TestResult {
let engine = TranslationEngine::default();
let body = json!({
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Partial answer"}],
"stop_reason": "refusal",
"usage": {"input_tokens": 10, "output_tokens": 5}
});

let output = engine
.translate_response(
WireFormat::AnthropicMessages,
WireFormat::OpenAiChat,
&body,
&TranslationPolicy::default(),
)?
.body;

assert_eq!(output["choices"][0]["finish_reason"], "content_filter");
Ok(())
}
60 changes: 60 additions & 0 deletions crates/switchyard-translation/tests/stream_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,43 @@ fn openai_chat_stream_event_translates_to_anthropic_message_events() -> TestResu
Ok(())
}

// Verifies streamed moderation stops are not reported to Anthropic clients as normal turns.
#[test]
fn openai_content_filter_stream_translates_to_anthropic_refusal() -> TestResult {
let engine = TranslationEngine::default();
let mut state =
StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::AnthropicMessages);
let chunk = json!({
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"model": "gpt-4o",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "content_filter"
}]
});

let mut events = engine.translate_event(
&mut state,
WireFormat::OpenAiChat,
WireFormat::AnthropicMessages,
&chunk,
)?;
events.extend(engine.finish_stream(&mut state, WireFormat::AnthropicMessages)?);

let terminal = events
.iter()
.find(|event| event["type"] == "message_delta")
.ok_or("missing Anthropic terminal delta")?;
assert_eq!(terminal["delta"]["stop_reason"], "refusal");
assert_eq!(
terminal["delta"]["stop_details"],
json!({"type": "refusal", "category": null, "explanation": null})
);
Ok(())
}

// Verifies Anthropic usage and stop events become terminal OpenAI chunks.
#[test]
fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult {
Expand Down Expand Up @@ -337,6 +374,29 @@ fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult {
Ok(())
}

// Verifies streamed Anthropic refusals remain distinguishable for OpenAI clients.
#[test]
fn anthropic_refusal_stream_translates_to_openai_content_filter() -> TestResult {
let engine = TranslationEngine::default();
let mut state =
StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::OpenAiChat);
let delta = json!({
"type": "message_delta",
"delta": {"stop_reason": "refusal"},
"usage": {"output_tokens": 1}
});

let events = engine.translate_event(
&mut state,
WireFormat::AnthropicMessages,
WireFormat::OpenAiChat,
&delta,
)?;

assert_eq!(events[0]["choices"][0]["finish_reason"], "content_filter");
Ok(())
}

// Verifies Chat target streams expose the served model while retaining source identity.
#[test]
fn anthropic_to_openai_chat_uses_served_model_without_losing_source_model() -> TestResult {
Expand Down