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
39 changes: 33 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,22 @@ 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(|reason| {
anthropic_stop_details(reason, response.extensions.fields.get("stop_details"))
})
.unwrap_or(Value::Null),
"usage": encode_anthropic_usage(&response.usage),
});
Ok(EncodedResponse {
Expand Down Expand Up @@ -977,6 +982,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 +993,30 @@ 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 Anthropic pairs with a `refusal` stop reason.
//
// An Anthropic source keeps its `stop_details` in provider extensions, so replay that
// object and preserve the named policy category and its explanation. Only a refusal
// synthesized from a provider that reports no category falls back to the null form,
// which Anthropic documents as the normal value for a refusal that maps to no named
// category.
fn anthropic_stop_details(reason: StopReason, source: Option<&Value>) -> Value {
match reason {
StopReason::ContentFilter => source
.filter(|details| !details.is_null())
.cloned()
.unwrap_or_else(|| {
json!({
"type": "refusal",
"category": Value::Null,
"explanation": Value::Null,
})
}),
_ => Value::Null,
}
}
34 changes: 32 additions & 2 deletions crates/switchyard-translation/src/codecs/anthropic/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ fn decode_anthropic_stream(
// Remember the provider stop reason: Anthropic delivers it here, on
// `message_delta`, while the terminal `message_stop` carries none of its own.
state.stop_reason = Some(stop_reason.to_string());
state.stop_details = object
.get("delta")
.and_then(Value::as_object)
.and_then(|delta| delta.get("stop_details"))
.filter(|details| !details.is_null())
.cloned();
out.push(LlmResponseChunk::MessageStop {
reason: Some(stop_reason.to_string()),
});
Expand Down Expand Up @@ -265,6 +271,10 @@ 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(),
state.stop_details.as_ref(),
),
},
"usage": anthropic_stream_usage(state),
}));
Expand Down Expand Up @@ -572,13 +582,33 @@ 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") => "refusal".to_string(),
Some(reason @ ("end_turn" | "max_tokens" | "tool_use" | "stop_sequence")) => {
reason.to_string()
}
_ => "end_turn".to_string(),
}
}

// Emits the metadata object Anthropic pairs with a `refusal` stop reason.
//
// A source that already reported `stop_details` carries the named policy category and
// its explanation, so replay that object verbatim. Only a refusal synthesized from a
// provider that reports no category falls back to the null form, which Anthropic
// documents as the normal value for a refusal that maps to no named category.
fn anthropic_stop_details(reason: Option<&str>, source: Option<&Value>) -> Value {
match reason {
Some("content_filter" | "refusal") => source.cloned().unwrap_or_else(|| {
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(),
}
}
3 changes: 3 additions & 0 deletions crates/switchyard-translation/src/codecs/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ pub struct StreamTranslationState {
pub(crate) output_tokens_seen: u64,
pub(crate) saw_backend_usage: bool,
pub(crate) stop_reason: Option<String>,
/// Refusal metadata carried by the source, replayed instead of a synthesized
/// object so a named policy category and its explanation are not flattened away.
pub(crate) stop_details: Option<Value>,
pub(crate) emitted_message_delta: bool,

pub(crate) next_content_index: usize,
Expand Down
76 changes: 76 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,79 @@ fn incomplete_responses_source_survives_translation() -> TestResult {
assert_eq!(output["choices"][0]["finish_reason"], "length");
Ok(())
}

// Verifies a moderation stop stays distinguishable from a normal turn in both
// directions, and that a named refusal category survives re-encoding.
#[test]
fn content_filter_and_refusal_translate_across_formats() -> TestResult {
let engine = TranslationEngine::default();
Comment thread
nachiketb-nvidia marked this conversation as resolved.

// An OpenAI moderation stop reaches Anthropic clients as `refusal`, not `end_turn`.
// OpenAI reports no policy category, so the refusal carries the null form that
// Anthropic documents for a refusal mapping to no named category.
let openai = 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,
&openai,
&TranslationPolicy::default(),
)?
.body;
assert_eq!(output["stop_reason"], "refusal");
assert_eq!(
output["stop_details"],
json!({"type": "refusal", "category": null, "explanation": null})
);

// The distinction survives the other direction rather than being flattened.
let anthropic = json!({
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "Partial answer"}],
"stop_reason": "refusal",
"stop_details": {
"type": "refusal",
"category": "cyber",
"explanation": "This request was declined because it could enable cyber harm."
},
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let output = engine
.translate_response(
WireFormat::AnthropicMessages,
WireFormat::OpenAiChat,
&anthropic,
&TranslationPolicy::default(),
)?
.body;
assert_eq!(output["choices"][0]["finish_reason"], "content_filter");

// Re-encoding a refusal keeps the category the source named instead of
// replacing it with the null form used for an unnamed refusal.
let output = engine
.translate_response(
WireFormat::AnthropicMessages,
WireFormat::AnthropicMessages,
&anthropic,
&TranslationPolicy {
preservation: switchyard_translation::PreservationPolicy::Disabled,
..TranslationPolicy::default()
},
)?
.body;
assert_eq!(output["stop_reason"], "refusal");
assert_eq!(output["stop_details"]["category"], "cyber");
Ok(())
}
75 changes: 75 additions & 0 deletions crates/switchyard-translation/tests/stream_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,81 @@ fn anthropic_stream_usage_and_stop_translate_to_openai_chunks() -> TestResult {
Ok(())
}

// Verifies streamed moderation stops stay distinguishable from normal turns in both
// directions, and that a named refusal category survives re-encoding.
#[test]
fn content_filter_and_refusal_streams_translate_across_formats() -> TestResult {
let engine = TranslationEngine::default();

// An OpenAI moderation stop reaches Anthropic clients as `refusal`, carrying the
// null form Anthropic documents for a refusal mapping to no named category.
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})
);

// The distinction survives the other direction rather than being flattened.
let mut state =
StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::OpenAiChat);
let delta = json!({
"type": "message_delta",
"delta": {
"stop_reason": "refusal",
"stop_details": {
"type": "refusal",
"category": "cyber",
"explanation": "This request was declined because it could enable cyber harm."
}
},
"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");

// Re-encoding a streamed refusal keeps the category the source named.
let mut state =
StreamTranslationState::new(WireFormat::AnthropicMessages, WireFormat::AnthropicMessages);
let mut events = engine.translate_event(
&mut state,
WireFormat::AnthropicMessages,
WireFormat::AnthropicMessages,
&delta,
)?;
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"]["category"], "cyber");
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
Loading