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
35 changes: 28 additions & 7 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ use crate::util::{
push_lossy, stable_id, string_value, validate_request_capabilities,
};

/// Reasoning controls the OpenAI Responses request body defines.
const RESPONSES_REASONING_KEYS: [&str; 3] = ["effort", "generate_summary", "summary"];

/// Format codec for OpenAI Responses payloads.
pub struct OpenAiResponsesCodec;

Expand Down Expand Up @@ -256,13 +259,31 @@ impl FormatCodec for OpenAiResponsesCodec {
json!({"format": encode_responses_text_format(response_format)}),
);
}
let mut reasoning = request
.reasoning
.raw
.as_ref()
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut reasoning = Map::new();
if let Some(raw) = request.reasoning.raw.as_ref().and_then(Value::as_object) {
// `raw` holds the source format's reasoning controls verbatim. Only the keys
// Responses itself defines can be replayed here. Carrying the rest forward
// publishes another provider's spelling under a Responses field name.
let mut dropped: Vec<&str> = Vec::new();
for (key, value) in raw {
if RESPONSES_REASONING_KEYS.contains(&key.as_str()) {
reasoning.insert(key.clone(), value.clone());
} else {
dropped.push(key.as_str());
}
}
if !dropped.is_empty() {
dropped.sort_unstable();
push_lossy(
&mut diagnostics,
_policy,
format!(
"reasoning controls have no OpenAI Responses representation and were dropped: {}",
dropped.join(", ")
),
)?;
}
}
if let Some(effort) = &request.reasoning.effort {
reasoning.insert("effort".to_string(), json!(effort));
}
Expand Down
83 changes: 83 additions & 0 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4048,3 +4048,86 @@ fn responses_stored_tool_outputs_stay_tool_results() -> TestResult {
assert_eq!(output["input"], outputs);
Ok(())
}

// Verifies Anthropic thinking controls are not republished under the Responses
// `reasoning` field, and that dropping them is reported.
#[test]
fn anthropic_thinking_controls_are_not_emitted_as_responses_reasoning() -> TestResult {
let engine = TranslationEngine::default();
let body = json!({
"model": "claude-opus-5",
"max_tokens": 32000,
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 10000}
});

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

assert_eq!(output.body.get("reasoning"), None);
assert!(!output.body.to_string().contains("budget_tokens"));
assert_eq!(output.diagnostics.len(), 1);
assert_eq!(output.diagnostics[0].code, "lossy_conversion");
assert!(
output.diagnostics[0].message.contains("budget_tokens")
&& output.diagnostics[0].message.contains("type")
);
Ok(())
}

// Verifies a caller asking to be told about information loss is told about
// reasoning controls the target cannot express.
#[test]
fn rejecting_lossy_conversion_rejects_untranslatable_reasoning_controls() -> TestResult {
let engine = TranslationEngine::default();
let policy = TranslationPolicy {
lossy_conversion_policy: LossyConversionPolicy::Reject,
..Default::default()
};
let body = json!({
"model": "claude-opus-5",
"max_tokens": 32000,
"messages": [{"role": "user", "content": "hi"}],
"thinking": {"type": "enabled", "budget_tokens": 10000}
});

let result = engine.translate_request(
WireFormat::AnthropicMessages,
WireFormat::OpenAiResponses,
&body,
&policy,
);

assert!(result.is_err());
Ok(())
}

// Verifies Responses reasoning controls that the format does define still carry
// through a rebuild without a diagnostic.
#[test]
fn responses_reasoning_summary_still_carries_through_a_rebuild() -> TestResult {
let engine = TranslationEngine::default();
let policy = TranslationPolicy::default();
let body = json!({
"model": "switchyard",
"input": "Fix the parser.",
"reasoning": {"effort": "high", "summary": "auto"}
});
let mut request = engine
.decode_request(WireFormat::OpenAiResponses, &body, &policy)?
.request;
request.preservation.requests.clear();

let output = engine.encode_request(WireFormat::OpenAiResponses, &request, &policy)?;

assert_eq!(
output.body["reasoning"],
json!({"effort": "high", "summary": "auto"})
);
assert!(output.diagnostics.is_empty());
Ok(())
}
Loading