From c876bdf0509f46e1203ae4be1416d80d84de7052 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Tue, 23 Jun 2026 13:48:55 -0400 Subject: [PATCH] feat: add Responses request translation core Signed-off-by: Francisco Javier Arceo --- filter/src/builtins/http/ai/mod.rs | 7 + .../http/ai/translation/chat_completions.rs | 387 ++++++++++++++++++ .../src/builtins/http/ai/translation/mod.rs | 263 ++++++++++++ 3 files changed, 657 insertions(+) create mode 100644 filter/src/builtins/http/ai/translation/chat_completions.rs create mode 100644 filter/src/builtins/http/ai/translation/mod.rs diff --git a/filter/src/builtins/http/ai/mod.rs b/filter/src/builtins/http/ai/mod.rs index 48bc0f83..c08519f5 100644 --- a/filter/src/builtins/http/ai/mod.rs +++ b/filter/src/builtins/http/ai/mod.rs @@ -22,6 +22,13 @@ mod prompt_enrich; pub(crate) mod store; #[cfg(feature = "ai-inference")] pub(crate) mod token_usage; +#[cfg(feature = "ai-inference")] +#[expect(clippy::allow_attributes, reason = "dead_code expect unfulfilled on module")] +#[allow( + dead_code, + reason = "Responses translation helpers are wired into the HTTP filter in a later stack entry" +)] +pub(crate) mod translation; mod token_usage_headers; diff --git a/filter/src/builtins/http/ai/translation/chat_completions.rs b/filter/src/builtins/http/ai/translation/chat_completions.rs new file mode 100644 index 00000000..26aecde6 --- /dev/null +++ b/filter/src/builtins/http/ai/translation/chat_completions.rs @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! `OpenAI` Responses API translation for Chat Completions-compatible providers. + +use serde_json::{Map, Value, json}; +use thiserror::Error; +use tracing::warn; + +/// Errors produced while translating between `Responses` and Chat Completions. +#[derive(Debug, Error)] +pub(crate) enum TranslationError { + /// The provided JSON value was not the expected object type. + #[error("{0} must be a JSON object")] + ExpectedObject(&'static str), +} + +/// Convert an `OpenAI` `Responses` create request into a Chat Completions request. +pub(crate) fn responses_request_to_chat_request(request: &Value) -> Result { + let obj = request + .as_object() + .ok_or(TranslationError::ExpectedObject("Responses request"))?; + + let mut chat = Map::new(); + copy_field(obj, &mut chat, "model"); + copy_field(obj, &mut chat, "stream"); + copy_field(obj, &mut chat, "temperature"); + copy_field(obj, &mut chat, "top_p"); + copy_field(obj, &mut chat, "presence_penalty"); + copy_field(obj, &mut chat, "frequency_penalty"); + copy_field(obj, &mut chat, "parallel_tool_calls"); + copy_field(obj, &mut chat, "service_tier"); + copy_field(obj, &mut chat, "extra_body"); + map_top_logprobs(obj, &mut chat); + map_reasoning_effort(obj, &mut chat); + + if let Some(max_output_tokens) = obj.get("max_output_tokens") { + chat.insert("max_completion_tokens".to_owned(), max_output_tokens.clone()); + } + + let messages = build_chat_messages(obj); + chat.insert("messages".to_owned(), Value::Array(messages)); + + if let Some(tools) = build_chat_tools(obj) { + chat.insert("tools".to_owned(), tools); + } + if let Some(tool_choice) = build_chat_tool_choice(obj) { + chat.insert("tool_choice".to_owned(), tool_choice); + } + + Ok(Value::Object(chat)) +} + +/// Copy a field from one JSON object to another. +fn copy_field(source: &Map, target: &mut Map, key: &str) { + if let Some(value) = source.get(key) { + target.insert(key.to_owned(), value.clone()); + } +} + +/// Map `top_logprobs` and required Chat Completions `logprobs` toggle together. +fn map_top_logprobs(source: &Map, target: &mut Map) { + if let Some(top_logprobs) = source.get("top_logprobs") { + target.insert("top_logprobs".to_owned(), top_logprobs.clone()); + target.insert("logprobs".to_owned(), Value::Bool(true)); + } +} + +/// Convert `Responses` reasoning controls to the Chat Completions field shape. +fn map_reasoning_effort(source: &Map, target: &mut Map) { + if let Some(effort) = source.get("reasoning").and_then(|reasoning| reasoning.get("effort")) { + target.insert("reasoning_effort".to_owned(), effort.clone()); + } +} + +/// Build Chat Completions messages from `Responses` instructions and input. +fn build_chat_messages(obj: &Map) -> Vec { + let mut messages = Vec::new(); + + if let Some(instructions) = obj.get("instructions").and_then(Value::as_str) + && !instructions.is_empty() + { + messages.push(json!({"role": "system", "content": instructions})); + } + + if let Some(input) = obj.get("input") { + append_input_messages(&mut messages, input); + } + + messages +} + +/// Append converted input messages to a Chat Completions message list. +fn append_input_messages(messages: &mut Vec, input: &Value) { + match input { + Value::String(text) => messages.push(json!({"role": "user", "content": text})), + Value::Array(items) => { + for item in items { + append_input_item(messages, item); + } + }, + Value::Object(_) => append_input_item(messages, input), + _ => { + warn!( + input_type = json_type_name(input), + "dropping unsupported Responses input during Chat Completions translation" + ); + }, + } +} + +/// Convert a single `Responses` input item into one Chat Completions message. +fn append_input_item(messages: &mut Vec, item: &Value) { + let Some(obj) = item.as_object() else { + return; + }; + + match obj.get("type").and_then(Value::as_str) { + Some("function_call") => append_function_call_item(messages, obj), + Some("function_call_output") => append_function_call_output_item(messages, obj), + Some("message") | None => append_message_item(messages, obj), + Some(input_type) => { + warn!( + input_type, + "dropping unsupported typed Responses input item during Chat Completions translation" + ); + }, + } +} + +/// Convert a Responses message item into a Chat Completions message. +fn append_message_item(messages: &mut Vec, obj: &Map) { + let role = obj.get("role").and_then(Value::as_str).unwrap_or("user"); + let content = obj.get("content").map_or_else(|| json!(""), convert_input_content); + messages.push(json!({"role": role, "content": content})); +} + +/// Convert a Responses function-call item into an assistant tool-call message. +fn append_function_call_item(messages: &mut Vec, obj: &Map) { + let Some(call_id) = obj.get("call_id").and_then(Value::as_str) else { + warn!("dropping Responses function_call without call_id during Chat Completions translation"); + return; + }; + let Some(name) = obj.get("name").and_then(Value::as_str) else { + warn!("dropping Responses function_call without name during Chat Completions translation"); + return; + }; + + messages.push(json!({ + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": stringify_chat_field(obj.get("arguments")), + } + } + ] + })); +} + +/// Convert a Responses function-call output into a Chat Completions tool message. +fn append_function_call_output_item(messages: &mut Vec, obj: &Map) { + let Some(call_id) = obj.get("call_id").and_then(Value::as_str) else { + warn!("dropping Responses function_call_output without call_id during Chat Completions translation"); + return; + }; + + messages.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "content": stringify_chat_field(obj.get("output")), + })); +} + +/// Convert an optional JSON field to Chat's string-valued history fields. +fn stringify_chat_field(value: Option<&Value>) -> String { + match value { + Some(Value::String(text)) => text.clone(), + Some(value) => value.to_string(), + None => String::new(), + } +} + +/// Convert `Responses` text content into the most compatible Chat form. +fn convert_input_content(content: &Value) -> Value { + match content { + Value::Array(parts) => convert_input_content_parts(parts), + _ => content.clone(), + } +} + +/// Convert `Responses` content parts, collapsing text-only content to a string. +fn convert_input_content_parts(parts: &[Value]) -> Value { + let mut converted = ConvertedContentParts::default(); + + for part in parts { + converted.push(part); + } + + converted.finish() +} + +/// Accumulates converted Chat content parts. +#[derive(Debug)] +struct ConvertedContentParts { + /// Raw text fragments for text-only content. + text_parts: Vec, + /// Chat content parts for mixed content. + chat_parts: Vec, + /// Whether every observed part was a text part. + all_text: bool, +} + +impl ConvertedContentParts { + /// Push one Responses content part. + fn push(&mut self, part: &Value) { + match part.get("type").and_then(Value::as_str) { + Some("input_text" | "output_text" | "text") => self.push_text(part), + Some("input_image") => { + if let Some(part) = convert_input_image_part(part) { + self.push_non_text(part); + } + }, + Some("input_file") => { + if let Some(part) = convert_input_file_part(part) { + self.push_non_text(part); + } + }, + Some(part_type) => { + warn!( + part_type, + "dropping unsupported Responses content part during Chat Completions translation" + ); + }, + None => warn!("dropping Responses content part without type during Chat Completions translation"), + } + } + + /// Push a text content part. + fn push_text(&mut self, part: &Value) { + if let Some(text) = part.get("text").and_then(Value::as_str) { + self.text_parts.push(text.to_owned()); + self.chat_parts.push(json!({"type": "text", "text": text})); + } + } + + /// Push a non-text content part. + fn push_non_text(&mut self, part: Value) { + self.all_text = false; + self.chat_parts.push(part); + } + + /// Finish conversion to either a text string or Chat content-part array. + fn finish(self) -> Value { + if self.all_text { + Value::String(self.text_parts.join("")) + } else { + Value::Array(self.chat_parts) + } + } +} + +impl Default for ConvertedContentParts { + fn default() -> Self { + Self { + text_parts: Vec::new(), + chat_parts: Vec::new(), + all_text: true, + } + } +} + +/// Convert a `Responses` image content part into Chat Completions shape. +fn convert_input_image_part(part: &Value) -> Option { + let obj = part.as_object()?; + let Some(url) = obj.get("image_url").cloned() else { + warn!("dropping Responses input_image without image_url during Chat Completions translation"); + return None; + }; + + let mut image_url = Map::new(); + image_url.insert("url".to_owned(), url); + copy_field(obj, &mut image_url, "detail"); + + Some(json!({ + "type": "image_url", + "image_url": Value::Object(image_url) + })) +} + +/// Convert a `Responses` file content part into Chat Completions shape. +fn convert_input_file_part(part: &Value) -> Option { + let obj = part.as_object()?; + let mut file = Map::new(); + copy_field(obj, &mut file, "file_id"); + copy_field(obj, &mut file, "filename"); + copy_field(obj, &mut file, "file_data"); + + if file.is_empty() { + warn!("dropping Responses input_file without file_id or file_data during Chat Completions translation"); + return None; + } + + Some(json!({ + "type": "file", + "file": Value::Object(file) + })) +} + +/// Convert `Responses` function tools into Chat Completions tools. +fn build_chat_tools(obj: &Map) -> Option { + let tools = obj.get("tools")?.as_array()?; + let mut mapped = Vec::new(); + + for tool in tools { + let Some(tool_obj) = tool.as_object() else { + continue; + }; + let Some("function") = tool_obj.get("type").and_then(Value::as_str) else { + warn!("dropping unsupported Responses tool during Chat Completions translation"); + continue; + }; + mapped.push(convert_function_tool(tool_obj)); + } + + if mapped.is_empty() { + return None; + } + + Some(Value::Array(mapped)) +} + +/// Convert one `Responses` function tool into Chat Completions function shape. +fn convert_function_tool(tool: &Map) -> Value { + let mut function = Map::new(); + copy_field(tool, &mut function, "name"); + copy_field(tool, &mut function, "description"); + copy_field(tool, &mut function, "parameters"); + copy_field(tool, &mut function, "strict"); + + json!({ + "type": "function", + "function": Value::Object(function) + }) +} + +/// Convert simple `Responses` tool-choice values into Chat Completions shape. +fn build_chat_tool_choice(obj: &Map) -> Option { + let choice = obj.get("tool_choice")?; + + match choice { + Value::String(_) => Some(choice.clone()), + Value::Object(choice_obj) => match choice_obj.get("type").and_then(Value::as_str) { + Some("function") => { + let mut function = Map::new(); + copy_field(choice_obj, &mut function, "name"); + Some(json!({"type": "function", "function": Value::Object(function)})) + }, + Some("allowed_tools") => Some(choice.clone()), + Some(other) => { + warn!( + tool_choice_type = other, + "dropping unsupported Responses tool_choice object" + ); + None + }, + None => None, + }, + _ => None, + } +} + +/// Return a stable JSON type name for diagnostics. +fn json_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} diff --git a/filter/src/builtins/http/ai/translation/mod.rs b/filter/src/builtins/http/ai/translation/mod.rs new file mode 100644 index 00000000..1a56a44d --- /dev/null +++ b/filter/src/builtins/http/ai/translation/mod.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Provider request and response translation helpers. + +pub(crate) mod chat_completions; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::cognitive_complexity, + clippy::expect_used, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::unwrap_used, + reason = "tests" +)] +mod tests { + use serde_json::{Value, json}; + + fn map(request: &Value) -> Value { + super::chat_completions::responses_request_to_chat_request(request).unwrap() + } + + #[test] + fn non_object_responses_request_returns_expected_object_error() { + let error = super::chat_completions::responses_request_to_chat_request(&json!("hello")).unwrap_err(); + assert_eq!(error.to_string(), "Responses request must be a JSON object"); + } + + #[test] + fn responses_request_maps_to_chat_completions_wire_shape() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "instructions": "Keep replies short.", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Remember the code word: ember."}]}], + "tools": [ + { + "type": "function", + "name": "store_memory", + "description": "Store a memory.", + "strict": true, + "parameters": {"type": "object", "properties": {"memory": {"type": "string"}}, "required": ["memory"]} + } + ], + "tool_choice": "auto", + "temperature": 0.2, + "top_p": 0.9, + "max_output_tokens": 64, + "stream": true + })); + + assert_eq!(mapped["model"], "gpt-4o-mini"); + assert_eq!(mapped["stream"], true); + assert_eq!(mapped["temperature"], 0.2); + assert_eq!(mapped["top_p"], 0.9); + assert_eq!(mapped["max_completion_tokens"], 64); + assert_eq!(mapped["tool_choice"], "auto"); + assert_eq!( + mapped["messages"][0], + json!({"role": "system", "content": "Keep replies short."}) + ); + assert_eq!( + mapped["messages"][1], + json!({"role": "user", "content": "Remember the code word: ember."}) + ); + assert_eq!( + mapped["tools"][0], + json!({ + "type": "function", + "function": { + "name": "store_memory", + "description": "Store a memory.", + "strict": true, + "parameters": {"type": "object", "properties": {"memory": {"type": "string"}}, "required": ["memory"]} + } + }) + ); + } + + #[test] + fn simple_inputs_map_or_drop_cleanly() { + let string_input = map(&json!({"model": "gpt-4o-mini", "instructions": "", "input": "Hello"})); + let object_input = map(&json!({"model": "gpt-4o-mini", "input": {"role": "developer", "content": "terse"}})); + let no_input = map(&json!({"model": "gpt-4o-mini"})); + let unsupported_input = map(&json!({"model": "gpt-4o-mini", "input": 42})); + + assert_eq!(string_input["messages"], json!([{"role": "user", "content": "Hello"}])); + assert_eq!( + object_input["messages"], + json!([{"role": "developer", "content": "terse"}]) + ); + assert_eq!(no_input["messages"], Value::Array(Vec::new())); + assert_eq!(unsupported_input["messages"], Value::Array(Vec::new())); + } + + #[test] + fn tool_choices_map_without_widening() { + let function_choice = map(&json!({ + "model": "gpt-4o-mini", "input": "hello", + "tool_choice": {"type": "function", "name": "lookup_weather"} + })); + let allowed_tools = map(&json!({ + "model": "gpt-4o-mini", "input": "hello", + "tool_choice": { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "lookup_weather"}] + } + })); + + assert_eq!( + function_choice["tool_choice"], + json!({"type": "function", "function": {"name": "lookup_weather"}}) + ); + assert_eq!( + allowed_tools["tool_choice"], + json!({"type": "allowed_tools", "mode": "auto", "tools": [{"type": "function", "name": "lookup_weather"}]}) + ); + } + + #[test] + fn unsupported_tools_drop_without_hiding_function_tools() { + let only_unsupported = map(&json!({ + "model": "gpt-4o-mini", + "input": "hello", + "tools": [{"type": "code_interpreter"}, {"type": "file_search"}] + })); + let mixed = map(&json!({ + "model": "gpt-4o-mini", + "input": "hello", + "tools": [ + {"type": "file_search"}, + {"type": "function", "name": "lookup_weather", "parameters": {"type": "object"}} + ] + })); + + assert!(only_unsupported.get("tools").is_none()); + assert_eq!( + mixed["tools"], + json!([{"type": "function", "function": {"name": "lookup_weather", "parameters": {"type": "object"}}}]) + ); + } + + #[test] + fn multimodal_content_parts_use_chat_shapes() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this image."}, + {"type": "input_image", "image_url": "https://example.com/cat.png", "detail": "high"}, + {"type": "input_file", "filename": "notes.txt", "file_data": "data:text/plain;base64,bm90ZXM="}, + {"type": "reasoning", "summary": []} + ] + } + ] + })); + + assert_eq!( + mapped["messages"][0], + json!({ + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "high"}}, + {"type": "file", "file": {"filename": "notes.txt", "file_data": "data:text/plain;base64,bm90ZXM="}} + ] + }) + ); + } + + #[test] + fn file_id_only_image_parts_are_skipped() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": [{ + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe the attached image."}, + {"type": "input_image", "file_id": "file-abc123"} + ] + }] + })); + assert_eq!( + mapped["messages"][0], + json!({"role": "user", "content": "Describe the attached image."}) + ); + } + + #[test] + fn tool_history_items_map_and_unknown_items_drop() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": [ + {"type": "reasoning", "summary": []}, + { + "type": "function_call", + "call_id": "call_weather", + "name": "lookup_weather", + "arguments": "{\"city\":\"NYC\"}" + }, + { + "type": "function_call_output", + "call_id": "call_weather", + "output": "{\"temperature\":72}" + }, + {"role": "user", "content": "continue"} + ] + })); + + assert_eq!( + mapped["messages"], + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_weather", + "type": "function", + "function": { + "name": "lookup_weather", + "arguments": "{\"city\":\"NYC\"}" + } + } + ] + }, + {"role": "tool", "tool_call_id": "call_weather", "content": "{\"temperature\":72}"}, + {"role": "user", "content": "continue"} + ]) + ); + } + + #[test] + fn responses_request_forwards_chat_generation_controls() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": "hello", + "temperature": 0.4, + "top_p": 0.8, + "presence_penalty": 0.3, + "frequency_penalty": 0.2, + "parallel_tool_calls": false, + "service_tier": "flex", + "top_logprobs": 5, + "reasoning": {"effort": "medium"}, + "extra_body": {"chat_template_kwargs": {"thinking": true}} + })); + + assert_eq!(mapped["presence_penalty"], 0.3); + assert_eq!(mapped["frequency_penalty"], 0.2); + assert_eq!(mapped["parallel_tool_calls"], false); + assert_eq!(mapped["service_tier"], "flex"); + assert_eq!(mapped["top_logprobs"], 5); + assert_eq!(mapped["logprobs"], true); + assert_eq!(mapped["reasoning_effort"], "medium"); + assert_eq!(mapped["extra_body"]["chat_template_kwargs"]["thinking"], true); + assert!(mapped.get("reasoning").is_none()); + } +}