diff --git a/Cargo.lock b/Cargo.lock index 402d83896..f1e926a35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,19 @@ dependencies = [ "rustversion", ] +[[package]] +name = "async-openai" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec08254d61379df136135d3d1ac04301be7699fd7d9e57655c63ac7d650a6922" +dependencies = [ + "bytes", + "derive_builder", + "getrandom 0.3.4", + "serde", + "serde_json", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -328,6 +341,72 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "diff" version = "0.1.13" @@ -351,6 +430,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dynamo-protocols" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1da866e573424e374c83d64dbf7807547054ebadcd85ddb2f9f67db06bf386" +dependencies = [ + "async-openai", + "derive_builder", + "futures", + "serde", + "serde_json", + "thiserror", + "tracing", + "url", + "uuid", +] + [[package]] name = "either" version = "1.16.0" @@ -416,6 +512,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -423,6 +534,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -431,6 +543,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -466,6 +589,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -744,6 +868,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1631,6 +1761,7 @@ dependencies = [ name = "switchyard-translation" version = "0.1.0" dependencies = [ + "dynamo-protocols", "pretty_assertions", "serde", "serde_json", @@ -1864,9 +1995,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1904,6 +2047,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1918,6 +2062,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "want" version = "0.3.1" diff --git a/crates/switchyard-translation/Cargo.toml b/crates/switchyard-translation/Cargo.toml index 4f7d7e9f2..87e7d4221 100644 --- a/crates/switchyard-translation/Cargo.toml +++ b/crates/switchyard-translation/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] +dynamo-protocols = "2.0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index c708c8d92..95e998e6b 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; +use serde::Serialize; use serde_json::{json, Map, Value}; use crate::codecs::common::{provider_extensions, reasoning_text_from_blocks, text_from_blocks}; @@ -26,6 +27,9 @@ use crate::util::{ exact_preserved_request, exact_preserved_response, json_string, push_lossy, stable_id, string_value, validate_request_capabilities, }; +use dynamo_protocols::types::responses::{ + CreateResponse as OpenAiResponsesRequest, InputParam, Tool, ToolChoiceParam, +}; /// Format codec for OpenAI Responses payloads. pub struct OpenAiResponsesCodec; @@ -36,7 +40,13 @@ impl FormatCodec for OpenAiResponsesCodec { } fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result { + // Dynamo owns relaxed Responses request parsing, including object-form + // tool_choice modes and input items that upstream OpenAI schemas reject. + let typed = serde_json::from_value::(body.clone()).ok(); let body = crate::util::object(body, "$")?; + if let Some(typed) = typed { + return decode_responses_request_typed(&typed, body, policy); + } let mut diagnostics = Vec::new(); let mut request = ConversationRequest { model: body @@ -262,6 +272,118 @@ impl FormatCodec for OpenAiResponsesCodec { } } +fn decode_responses_request_typed( + schema: &OpenAiResponsesRequest, + raw: &Map, + policy: &TranslationPolicy, +) -> Result { + let mut diagnostics = Vec::new(); + let mut request = ConversationRequest { + model: schema + .model + .as_deref() + .filter(|model| !model.is_empty()) + .map(ToOwned::to_owned), + output: OutputParams { + max_output_tokens: schema.max_output_tokens.map(u64::from), + response_format: schema + .text + .as_ref() + .and_then(|text| decode_responses_text_format(Some(&typed_value(text)))), + }, + reasoning: ReasoningParams { + effort: schema.reasoning.as_ref().and_then(typed_reasoning_effort), + raw: schema.reasoning.as_ref().map(typed_value), + }, + sampling: SamplingParams { + temperature: schema.temperature.map(f64::from), + top_p: schema.top_p.map(f64::from), + top_k: None, + }, + stream: schema.stream.unwrap_or(false), + preservation: capture_request_preservation( + WireFormat::OpenAiResponses, + &Value::Object(raw.clone()), + policy, + ), + ..ConversationRequest::default() + }; + if let Some(instructions) = schema + .instructions + .as_deref() + .filter(|instructions| !instructions.is_empty()) + { + request.instructions.push(crate::ir::InstructionBlock { + role: Role::System, + content: vec![ContentBlock::Text { + text: instructions.to_string(), + }], + }); + } + request.messages = decode_responses_input_typed(&schema.input, &mut diagnostics, policy)?; + request.tools = schema + .tools + .as_deref() + .map(decode_responses_tools_typed) + .unwrap_or_default(); + request.tool_choice = schema + .tool_choice + .as_ref() + .and_then(decode_responses_tool_choice_typed); + request.extensions.fields = responses_request_extensions(raw); + Ok(DecodedRequest { + request, + diagnostics, + }) +} + +fn responses_request_extensions(body: &Map) -> Map { + provider_extensions( + body, + &[ + "model", + "instructions", + "input", + "tools", + "tool_choice", + "max_output_tokens", + "text", + "reasoning", + "temperature", + "top_p", + "stream", + ], + ) +} + +fn typed_value(value: &T) -> Value { + serde_json::to_value(value).unwrap_or(Value::Null) +} + +fn typed_reasoning_effort(reasoning: &T) -> Option { + typed_value(reasoning) + .as_object() + .and_then(|object| object.get("effort")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn decode_responses_input_typed( + input: &InputParam, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + decode_responses_input(&typed_value(input), diagnostics, policy) +} + +fn decode_responses_tools_typed(tools: &[Tool]) -> Vec { + decode_responses_tools(Some(&Value::Array(tools.iter().map(typed_value).collect()))) +} + +fn decode_responses_tool_choice_typed(choice: &ToolChoiceParam) -> Option { + decode_responses_tool_choice(&typed_value(choice)) +} + // Decodes Responses `input` into ordered normalized messages. fn decode_responses_input( value: &Value, diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index ea15c63db..1f51c9e67 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -742,6 +742,35 @@ fn responses_chat_compatible_extensions_survive_to_openai_chat() -> TestResult { Ok(()) } +// Verifies Dynamo-owned Responses schemas accept Anthropic-style tool choice modes. +#[test] +fn responses_object_tool_choice_mode_survives_to_openai_chat() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-4", + "input": "hi", + "tools": [{ + "type": "function", + "name": "lookup", + "description": "Lookup data", + "parameters": {"type": "object"} + }], + "tool_choice": {"type": "auto", "disable_parallel_tool_use": true} + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!(output["tool_choice"], "auto"); + Ok(()) +} + // Verifies Responses JSON schema text format maps to Chat response_format shape. #[test] fn responses_json_schema_text_format_maps_to_chat_response_format() -> TestResult {