Skip to content
Draft
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
66 changes: 51 additions & 15 deletions crates/switchyard-translation/src/codecs/anthropic/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,22 +298,43 @@ impl FormatCodec for AnthropicMessagesCodec {
fn encode_response(
&self,
response: &AggLlmResponse,
_policy: &TranslationPolicy,
policy: &TranslationPolicy,
) -> Result<EncodedResponse> {
if let Some(body) = exact_preserved_response(
&response.preservation,
WireFormat::AnthropicMessages,
_policy,
policy,
) {
return Ok(EncodedResponse {
body,
diagnostics: Vec::new(),
});
}
let output = response.first_output();
let content = output
.map(|output| encode_anthropic_content(&output.content))
.unwrap_or_else(|| vec![json!({"type": "text", "text": ""})]);
let mut diagnostics = Vec::new();
if response.outputs.len() > 1 {
push_lossy(
&mut diagnostics,
policy,
"Anthropic response encoding cannot represent multiple outputs",
)?;
}
let output = response
.outputs
.iter()
.find(|output| {
output.content.iter().any(|block| {
!matches!(
block,
ContentBlock::Unknown { provider, .. }
if provider.as_str() != WireFormat::AnthropicMessages.as_str()
)
})
})
.or_else(|| response.first_output());
let content = match output {
Some(output) => encode_anthropic_content(&output.content, &mut diagnostics, policy)?,
None => vec![json!({"type": "text", "text": ""})],
};
let body = json!({
"id": response.id.clone().unwrap_or_else(|| "msg_switchyard".to_string()),
"type": "message",
Expand All @@ -328,8 +349,8 @@ impl FormatCodec for AnthropicMessagesCodec {
"usage": encode_anthropic_usage(&response.usage),
});
Ok(EncodedResponse {
body: embed_preservation(body, &response.preservation, _policy),
diagnostics: Vec::new(),
body: embed_preservation(body, &response.preservation, policy),
diagnostics,
})
}
}
Expand Down Expand Up @@ -684,16 +705,31 @@ fn encode_anthropic_content_with_policy(
Ok(blocks)
}

// Encodes content without producing diagnostics for response paths.
fn encode_anthropic_content(content: &[ContentBlock]) -> Vec<Value> {
let mut blocks = content
.iter()
.flat_map(encode_one_anthropic_response_block)
.collect::<Vec<_>>();
// Encodes response content without leaking foreign provider block shapes.
fn encode_anthropic_content(
content: &[ContentBlock],
diagnostics: &mut Vec<TranslationDiagnostic>,
policy: &TranslationPolicy,
) -> Result<Vec<Value>> {
let mut blocks = Vec::new();
for block in content {
match block {
ContentBlock::Unknown { provider, .. }
if provider.as_str() != WireFormat::AnthropicMessages.as_str() =>
{
push_lossy(
diagnostics,
policy,
"Anthropic response encoding drops foreign unknown content blocks",
)?;
}
other => blocks.extend(encode_one_anthropic_response_block(other)),
}
}
if blocks.is_empty() {
blocks.push(json!({"type": "text", "text": ""}));
}
blocks
Ok(blocks)
}

// Encodes response content, where synthetic reasoning may be shown to clients.
Expand Down
119 changes: 80 additions & 39 deletions crates/switchyard-translation/src/codecs/openai_chat/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,43 +266,43 @@ impl FormatCodec for OpenAiChatCodec {
_policy,
),
};
if let Some(choice) = object
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(Value::as_object)
{
let message = choice
.get("message")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut content = decode_openai_content(
message.get("content").unwrap_or(&Value::Null),
WireFormat::OpenAiChat,
&mut Vec::new(),
&TranslationPolicy::default(),
"$.choices[0].message.content",
)?;
prepend_openai_reasoning_blocks(&mut content, &message);
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_calls.iter().enumerate() {
if let Some(call) = decode_openai_tool_call(
tool_call,
index + 1,
&TranslationPolicy::default(),
)? {
content.push(ContentBlock::ToolCall(call));
if let Some(choices) = object.get("choices").and_then(Value::as_array) {
for (choice_index, choice) in choices.iter().enumerate() {
let Some(choice) = choice.as_object() else {
continue;
};
let message = choice
.get("message")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut content = decode_openai_content(
message.get("content").unwrap_or(&Value::Null),
WireFormat::OpenAiChat,
&mut Vec::new(),
&TranslationPolicy::default(),
format!("$.choices[{choice_index}].message.content"),
)?;
prepend_openai_reasoning_blocks(&mut content, &message);
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_calls.iter().enumerate() {
if let Some(call) = decode_openai_tool_call(
tool_call,
index + 1,
&TranslationPolicy::default(),
)? {
content.push(ContentBlock::ToolCall(call));
}
}
}
response.outputs.push(ResponseOutput {
role: Role::Assistant,
content,
stop_reason: Some(map_openai_finish_reason(
choice.get("finish_reason").and_then(Value::as_str),
)),
});
}
response.outputs.push(ResponseOutput {
role: Role::Assistant,
content,
stop_reason: Some(map_openai_finish_reason(
choice.get("finish_reason").and_then(Value::as_str),
)),
});
}

Ok(DecodedResponse {
Expand All @@ -314,17 +314,58 @@ impl FormatCodec for OpenAiChatCodec {
fn encode_response(
&self,
response: &AggLlmResponse,
_policy: &TranslationPolicy,
policy: &TranslationPolicy,
) -> Result<EncodedResponse> {
if let Some(body) =
exact_preserved_response(&response.preservation, WireFormat::OpenAiChat, _policy)
exact_preserved_response(&response.preservation, WireFormat::OpenAiChat, policy)
{
return Ok(EncodedResponse {
body,
diagnostics: Vec::new(),
});
}
let output = response.first_output();
let mut diagnostics = Vec::new();
if response.outputs.len() > 1 {
push_lossy(
&mut diagnostics,
policy,
"OpenAI Chat response encoding cannot represent multiple outputs",
)?;
}
let output = response
.outputs
.iter()
.find(|output| {
output.content.iter().any(|block| {
matches!(
block,
ContentBlock::Text { .. }
| ContentBlock::Refusal { .. }
| ContentBlock::Reasoning { .. }
| ContentBlock::ToolCall(_)
)
})
})
.or_else(|| response.first_output());
if output.is_some_and(|output| {
output.content.iter().any(|block| {
matches!(
block,
ContentBlock::Image { .. }
| ContentBlock::Audio { .. }
| ContentBlock::Video { .. }
| ContentBlock::File { .. }
| ContentBlock::ToolResult(_)
| ContentBlock::Unknown { .. }
)
})
}) {
push_lossy(
&mut diagnostics,
policy,
"OpenAI Chat response encoding drops unsupported content blocks",
)?;
}
let content = output
.map(|output| text_from_blocks(&output.content, ""))
.unwrap_or_default();
Expand Down Expand Up @@ -381,8 +422,8 @@ impl FormatCodec for OpenAiChatCodec {
"usage": encode_openai_usage(&response.usage),
});
Ok(EncodedResponse {
body: embed_preservation(body, &response.preservation, _policy),
diagnostics: Vec::new(),
body: embed_preservation(body, &response.preservation, policy),
diagnostics,
})
}
}
Expand Down
57 changes: 48 additions & 9 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,18 @@ impl FormatCodec for OpenAiResponsesCodec {
fn encode_response(
&self,
response: &AggLlmResponse,
_policy: &TranslationPolicy,
policy: &TranslationPolicy,
) -> Result<EncodedResponse> {
if let Some(body) =
exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, _policy)
exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, policy)
{
return Ok(EncodedResponse {
body,
diagnostics: Vec::new(),
});
}
let mut diagnostics = Vec::new();
let output = encode_responses_output(&response.outputs, &mut diagnostics, policy)?;
Ok(EncodedResponse {
body: embed_preservation(
json!({
Expand All @@ -250,16 +252,16 @@ impl FormatCodec for OpenAiResponsesCodec {
"created_at": 0,
"model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
"status": "completed",
"output": encode_responses_output(&response.outputs),
"output": output,
"usage": encode_responses_usage(&response.usage),
"parallel_tool_calls": true,
"tool_choice": "auto",
"tools": [],
}),
&response.preservation,
_policy,
policy,
),
diagnostics: Vec::new(),
diagnostics,
})
}
}
Expand Down Expand Up @@ -1063,13 +1065,50 @@ fn decode_responses_output_item(
content: decode_responses_reasoning_item(item),
stop_reason: None,
})),
_ => Ok(None),
_ => Ok(Some(ResponseOutput {
role: Role::Assistant,
// Keep the unrepresentable item in the neutral response so a
// cross-format encoder can diagnose the loss. Exact same-format
// replay comes from response preservation: `ContentBlock::Unknown`
// cannot distinguish this top-level item from an unknown nested
// message-content block.
content: vec![ContentBlock::Unknown {
provider: WireFormat::OpenAiResponses.into(),
raw: Value::Object(item.clone()),
}],
stop_reason: None,
})),
}
}

// Encodes normalized response outputs into Responses output items.
fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
Value::Array(
fn encode_responses_output(
outputs: &[ResponseOutput],
diagnostics: &mut Vec<TranslationDiagnostic>,
policy: &TranslationPolicy,
) -> Result<Value> {
if outputs
.iter()
.flat_map(|output| &output.content)
.any(|block| {
matches!(
block,
ContentBlock::Image { .. }
| ContentBlock::Audio { .. }
| ContentBlock::Video { .. }
| ContentBlock::File { .. }
| ContentBlock::ToolResult(_)
| ContentBlock::Unknown { .. }
)
})
{
push_lossy(
diagnostics,
policy,
"Responses response encoding drops unsupported content blocks",
)?;
}
Ok(Value::Array(
outputs
.iter()
.flat_map(|output| {
Expand Down Expand Up @@ -1112,7 +1151,7 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
items
})
.collect(),
)
))
}

// Encodes private reasoning as a separate Responses output item.
Expand Down
Loading
Loading