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
52 changes: 52 additions & 0 deletions apis/src/openai/responses/responses_to_chat_completions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,58 @@ async fn canonical_state_is_translated_and_arms_response() {
assert_eq!(context.get_metadata(CREATED_AT_KEY), Some("1700000000"));
}

#[tokio::test]
async fn malformed_responses_input_is_rejected_before_request_translation() {
let cases = [
(
"scalar input",
json!({"model": "m", "input": 42}),
"unsupported Responses input type for Chat Completions translation: number",
),
(
"non-object input item",
json!({"model": "m", "input": [42]}),
"Responses input item must be a JSON object",
),
(
"function call without call_id",
json!({"model": "m", "input": [{"type": "function_call", "name": "lookup", "arguments": "{}"}]}),
"Responses function_call input item is missing required field `call_id`",
),
];

for (case, request_body, expected_message) in cases {
let filter = ResponsesToChatCompletionsFilter::from_config(&serde_yaml::Value::Null).unwrap();
let request = crate::test_utils::make_request(http::Method::POST, "/v1/responses");
let mut context = crate::test_utils::make_filter_context(&request);
context.set_metadata("openai_responses_format.format", "openai_responses");
context
.extensions
.insert(ResponsesState::from_request_body(request_body.clone()));
let original = Bytes::from(serde_json::to_vec(&request_body).unwrap());
let mut body = Some(original.clone());

let action = filter.on_request_body(&mut context, &mut body, true).await.unwrap();

let FilterAction::Reject(rejection) = action else {
panic!("{case} should be rejected");
};
assert_eq!(rejection.status, 400, "{case} should produce a client error");
let parsed: serde_json::Value = serde_json::from_slice(rejection.body.as_deref().unwrap()).unwrap();
assert_eq!(parsed["error"]["code"], "invalid_request_error", "{case}");
assert_eq!(parsed["error"]["message"], expected_message, "{case}");
assert_eq!(
body.as_deref(),
Some(original.as_ref()),
"{case} should not rewrite the body"
);
assert!(
context.get_metadata(ARMED_KEY).is_none(),
"{case} must not arm response processing"
);
}
}

#[tokio::test]
async fn rehydrated_previous_response_id_translates_full_history() {
let filter = ResponsesToChatCompletionsFilter::from_config(&serde_yaml::Value::Null).unwrap();
Expand Down
108 changes: 75 additions & 33 deletions apis/src/openai/translation/chat_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

use serde_json::{Map, Number, Value, json};
use thiserror::Error;
use tracing::warn;

// -----------------------------------------------------------------------------
// Constants
Expand Down Expand Up @@ -173,6 +172,25 @@ pub(crate) enum TranslationError {
/// The provided JSON value was not the expected object type.
#[error("{0} must be a JSON object")]
ExpectedObject(&'static str),
/// A Responses input value has no valid Chat Completions representation.
#[error("unsupported Responses input type for Chat Completions translation: {0}")]
UnsupportedInputType(&'static str),
/// A Responses input item omitted a field required for faithful translation.
#[error("Responses {item_type} input item is missing required field `{field}`")]
MissingInputItemField {
/// Stable Responses input item type.
item_type: &'static str,
/// Required field that was absent.
field: &'static str,
},
/// A Responses input item field has the wrong type for translation.
#[error("Responses {item_type} input item field `{field}` must be a string")]
InvalidInputItemStringField {
/// Stable Responses input item type.
item_type: &'static str,
/// String field whose value had another JSON type.
field: &'static str,
},
/// A Responses input item has no Chat Completions-compatible representation.
#[error("unsupported Responses input item type for Chat Completions translation: {0}")]
UnsupportedInputItemType(String),
Expand Down Expand Up @@ -232,6 +250,7 @@ fn translate_responses_request(request: &Value, overrides: RequestOverrides<'_>)
let obj = request
.as_object()
.ok_or(TranslationError::ExpectedObject("Responses request"))?;
validate_input_container(obj.get("input"))?;

let mut chat = Map::new();
map_request_parameters(obj, &mut chat);
Expand Down Expand Up @@ -397,12 +416,8 @@ fn append_input_messages(messages: &mut Vec<Value>, input: &Value) -> Result<(),
Value::String(text) => messages.push(json!({"role": "user", "content": text})),
Value::Array(items) => append_input_item_sequence(messages, items)?,
Value::Object(_) => append_input_item_sequence(messages, std::slice::from_ref(input))?,
_ => {
warn!(
input_type = json_type_name(input),
"dropping unsupported Responses input during Chat Completions translation"
);
},
Value::Null => {},
_ => return Err(unsupported_input_type(input)),
}

Ok(())
Expand All @@ -415,9 +430,7 @@ fn append_input_item_sequence(messages: &mut Vec<Value>, items: &[Value]) -> Res
if let Some(obj) = item.as_object()
&& obj.get("type").and_then(Value::as_str) == Some("function_call")
{
if let Some(tool_call) = function_call_tool_call(obj) {
pending_tool_calls.push(tool_call);
}
pending_tool_calls.push(function_call_tool_call(obj)?);
continue;
}

Expand All @@ -444,11 +457,11 @@ fn flush_pending_function_calls(messages: &mut Vec<Value>, pending_tool_calls: &
/// Convert a single `Responses` input item into one Chat Completions message.
fn append_input_item(messages: &mut Vec<Value>, item: &Value) -> Result<(), TranslationError> {
let Some(obj) = item.as_object() else {
return Ok(());
return Err(TranslationError::ExpectedObject("Responses input item"));
};

match obj.get("type").and_then(Value::as_str) {
Some("function_call_output") => append_tool_output(messages, obj),
Some("function_call_output") => append_tool_output(messages, obj)?,
Some("message") => append_message_item(messages, obj)?,
Some("compaction") => append_compaction_item(messages, obj),
None if obj.contains_key("role") || obj.contains_key("content") => append_message_item(messages, obj)?,
Expand All @@ -461,10 +474,12 @@ fn append_input_item(messages: &mut Vec<Value>, item: &Value) -> Result<(), Tran

/// Convert a Responses message item into a Chat Completions message.
fn append_message_item(messages: &mut Vec<Value>, obj: &Map<String, Value>) -> Result<(), TranslationError> {
let role = obj.get("role").and_then(Value::as_str).unwrap_or("user");
let content = obj
.get("content")
.map_or_else(|| Ok(json!("")), convert_input_content)?;
let role = required_input_item_string(obj, "message", "role")?;
let content = obj.get("content").ok_or(TranslationError::MissingInputItemField {
item_type: "message",
field: "content",
})?;
let content = convert_input_content(content)?;
messages.push(json!({"role": role, "content": content}));
Ok(())
}
Expand All @@ -490,38 +505,65 @@ fn append_compaction_item(messages: &mut Vec<Value>, obj: &Map<String, Value>) {
}

/// Convert one Responses function-call item to a Chat tool-call object.
fn function_call_tool_call(obj: &Map<String, Value>) -> Option<Value> {
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 None;
};
let Some(name) = obj.get("name").and_then(Value::as_str) else {
warn!("dropping Responses function_call without name during Chat Completions translation");
return None;
};
fn function_call_tool_call(obj: &Map<String, Value>) -> Result<Value, TranslationError> {
let call_id = required_input_item_string(obj, "function_call", "call_id")?;
let name = required_input_item_string(obj, "function_call", "name")?;
let arguments = obj.get("arguments").ok_or(TranslationError::MissingInputItemField {
item_type: "function_call",
field: "arguments",
})?;

Some(json!({
Ok(json!({
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": chat_string_field(obj.get("arguments")),
"arguments": chat_string_field(Some(arguments)),
}
}))
}

/// Convert a `Responses` function call output item into a Chat tool message.
fn append_tool_output(messages: &mut Vec<Value>, obj: &Map<String, Value>) {
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;
};
fn append_tool_output(messages: &mut Vec<Value>, obj: &Map<String, Value>) -> Result<(), TranslationError> {
let call_id = required_input_item_string(obj, "function_call_output", "call_id")?;
let output = obj.get("output").ok_or(TranslationError::MissingInputItemField {
item_type: "function_call_output",
field: "output",
})?;

messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": chat_string_field(obj.get("output"))
"content": chat_string_field(Some(output))
}));
Ok(())
}

/// Validate the outer Responses input shape before canonical state overrides
/// can hide an invalid scalar value.
fn validate_input_container(input: Option<&Value>) -> Result<(), TranslationError> {
match input {
None | Some(Value::Null | Value::String(_) | Value::Array(_) | Value::Object(_)) => Ok(()),
Some(input) => Err(unsupported_input_type(input)),
}
}

/// Build a stable error for an unsupported outer input value.
fn unsupported_input_type(input: &Value) -> TranslationError {
TranslationError::UnsupportedInputType(json_type_name(input))
}

/// Read a required string field from a Responses input item.
fn required_input_item_string<'a>(
obj: &'a Map<String, Value>,
item_type: &'static str,
field: &'static str,
) -> Result<&'a str, TranslationError> {
match obj.get(field) {
Some(Value::String(value)) => Ok(value),
Some(_) => Err(TranslationError::InvalidInputItemStringField { item_type, field }),
None => Err(TranslationError::MissingInputItemField { item_type, field }),
}
}

/// Convert an optional JSON field to Chat's string-valued history fields.
Expand Down
Loading
Loading