fix(neuron): accept bare {role,content} input on /v1/responses (agent-zero) #80

Merged
grenade merged 1 commits from fix/responses-easy-message-input into main 2026-06-26 16:53:09 +00:00
2 changed files with 266 additions and 20 deletions

View File

@@ -66,14 +66,48 @@ pub struct ResponsesRequest {
pub extra: Value,
}
/// `input` is either a single string or an array of typed items.
/// `input` is either a single string or an array of items.
/// `#[serde(untagged)]` so the wire shape `"input": "hi"` and
/// `"input": [{...}]` both deserialize.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesInput {
Text(String),
Items(Vec<ResponsesInputItem>),
Items(Vec<ResponsesInputElement>),
}
/// One element of an `input` array.
///
/// OpenAI's Responses API accepts three shapes here, and real clients
/// use all of them — most notably agent-zero (via litellm), which
/// sends the bare "easy message" form. We must tolerate every shape,
/// because `input` is an `#[serde(untagged)]` array: a single element
/// that matches no variant fails the *entire* request with a 422
/// (`did not match any variant of untagged enum ResponsesInput`).
///
/// 1. [`Self::Typed`] — an item carrying an explicit `"type"`
/// discriminant (`message`, `function_call`, `function_call_output`,
/// `reasoning`).
/// 2. [`Self::EasyMessage`] — a bare `{role, content}` with **no**
/// `type` field. This is OpenAI's `EasyInputMessage` and what
/// litellm emits for every turn. `content` is optional so an
/// assistant turn carrying only tool calls (`content: null`) still
/// parses.
/// 3. [`Self::Other`] — anything else, captured as raw JSON and
/// dropped during translation. This is the forward-compat escape
/// hatch that mirrors [`ResponsesRequest::extra`] at the item
/// level: an unmodeled item type can never again reject the whole
/// request.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsesInputElement {
Typed(ResponsesInputItem),
EasyMessage {
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<ResponsesMessageContent>,
},
Other(Value),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -91,8 +125,11 @@ pub enum ResponsesInputItem {
name: String,
arguments: String,
},
/// User is feeding a tool result back into the model.
FunctionCallOutput { call_id: String, output: String },
/// User is feeding a tool result back into the model. `output`
/// is a `Value` because OpenAI allows it to be either a plain
/// string or an array of content parts; the translator renders
/// either form to text rather than losing the tool result.
FunctionCallOutput { call_id: String, output: Value },
/// Reasoning items emitted by o-series models. Accepted but
/// not forwarded to the model — neuron's candle path doesn't
/// surface reasoning separately yet.
@@ -132,6 +169,11 @@ pub enum ResponsesContentPart {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<Value>,
},
/// Any content-part type we don't model (e.g. `refusal`, audio).
/// Captured as a unit so an unknown part can't reject the whole
/// request; dropped during translation.
#[serde(other)]
Unknown,
}
// ── Response (non-streaming) ─────────────────────────────────────────
@@ -277,20 +319,116 @@ mod tests {
ResponsesInput::Items(items) => {
assert_eq!(items.len(), 1);
match &items[0] {
ResponsesInputItem::Message { role, content } => {
ResponsesInputElement::Typed(ResponsesInputItem::Message { role, content }) => {
assert_eq!(role, "user");
match content {
ResponsesMessageContent::Text(t) => assert_eq!(t, "hi"),
other => panic!("expected Text content, got {other:?}"),
}
}
other => panic!("expected Message item, got {other:?}"),
other => panic!("expected typed Message item, got {other:?}"),
}
}
other => panic!("expected Items, got {other:?}"),
}
}
#[test]
fn deserialises_bare_easy_message_without_type() {
// The shape agent-zero (via litellm) actually sends: `input`
// items are bare `{role, content}` with NO `type` field. This
// is the exact payload that was returning 422.
let raw = r#"{
"model": "Qwen/Qwen3.6-27B",
"store": true,
"tools": [{"type": "function", "name": "x", "description": "d", "parameters": {}}],
"input": [
{"role": "system", "content": "you are helpful"},
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
{"role": "user", "content": "hi"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let items = match req.input {
ResponsesInput::Items(i) => i,
other => panic!("expected Items, got {other:?}"),
};
assert_eq!(items.len(), 3);
for el in &items {
assert!(
matches!(el, ResponsesInputElement::EasyMessage { .. }),
"expected EasyMessage, got {el:?}"
);
}
// `tools` / `store` ride through `extra`, not `input`.
assert!(req.extra.get("tools").is_some());
assert_eq!(req.extra.get("store"), Some(&Value::Bool(true)));
}
#[test]
fn tolerates_null_content_and_unknown_item_types() {
// An assistant turn carrying only tool calls has `content: null`;
// and a future/unmodeled item type must not 422 the request.
let raw = r#"{
"model": "m",
"input": [
{"role": "assistant", "content": null},
{"type": "item_reference", "id": "abc"},
{"type": "function_call_output", "call_id": "c1",
"output": [{"type": "output_text", "text": "result"}]},
{"role": "user", "content": "go"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let items = match req.input {
ResponsesInput::Items(i) => i,
other => panic!("expected Items, got {other:?}"),
};
assert_eq!(items.len(), 4);
assert!(matches!(
&items[0],
ResponsesInputElement::EasyMessage { content: None, .. }
));
assert!(matches!(&items[1], ResponsesInputElement::Other(_)));
assert!(matches!(
&items[2],
ResponsesInputElement::Typed(ResponsesInputItem::FunctionCallOutput { .. })
));
assert!(matches!(
&items[3],
ResponsesInputElement::EasyMessage { .. }
));
}
#[test]
fn tolerates_unknown_content_part_type() {
// A `refusal` (or any unmodeled) content part must parse, not 422.
let raw = r#"{
"model": "m",
"input": [
{"role": "assistant", "content": [
{"type": "refusal", "refusal": "no"},
{"type": "output_text", "text": "ok"}
]}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let items = match req.input {
ResponsesInput::Items(i) => i,
other => panic!("expected Items, got {other:?}"),
};
let parts = match &items[0] {
ResponsesInputElement::EasyMessage {
content: Some(ResponsesMessageContent::Parts(p)),
..
} => p,
other => panic!("expected EasyMessage with Parts, got {other:?}"),
};
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], ResponsesContentPart::Unknown));
assert!(matches!(&parts[1], ResponsesContentPart::OutputText { .. }));
}
#[test]
fn deserialises_input_with_image() {
let raw = r#"{
@@ -308,10 +446,10 @@ mod tests {
other => panic!("expected Items, got {other:?}"),
};
let parts = match &items[0] {
ResponsesInputItem::Message {
ResponsesInputElement::Typed(ResponsesInputItem::Message {
content: ResponsesMessageContent::Parts(p),
..
} => p,
}) => p,
other => panic!("expected Parts, got {other:?}"),
};
assert_eq!(parts.len(), 2);

View File

@@ -29,9 +29,9 @@
use cortex_core::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
use cortex_core::responses::{
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputItem,
ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem, ResponsesRequest,
ResponsesResponse, ResponsesUsage, events,
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputElement,
ResponsesInputItem, ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem,
ResponsesRequest, ResponsesResponse, ResponsesUsage, events,
};
use serde_json::{Value, json};
use tokio::sync::mpsc;
@@ -109,8 +109,26 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
});
}
ResponsesInput::Items(items) => {
for item in items {
if let Some(msg) = input_item_to_chat(item) {
for element in items {
let msg = match element {
ResponsesInputElement::Typed(item) => input_item_to_chat(item),
// Bare `{role, content}` (OpenAI EasyInputMessage —
// what litellm/agent-zero emit). `content: null`
// (e.g. an assistant turn carrying only tool calls)
// collapses to an empty string so the turn is kept.
ResponsesInputElement::EasyMessage { role, content } => Some(ChatMessage {
role,
content: content
.map(message_content_to_chat)
.unwrap_or_else(|| MessageContent::Text(String::new())),
extra: Value::Object(Default::default()),
}),
// Forward-compat: an item shape we don't model.
// Dropped rather than rejected (see
// `ResponsesInputElement::Other`).
ResponsesInputElement::Other(_) => None,
};
if let Some(msg) = msg {
messages.push(msg);
}
}
@@ -159,11 +177,18 @@ fn input_item_to_chat(item: ResponsesInputItem) -> Option<ChatMessage> {
})
}
ResponsesInputItem::FunctionCallOutput { call_id, output } => {
// `output` is either a plain string or an array of content
// parts. Render a string as-is; anything else to compact
// JSON so the tool result text reaches the model intact.
let output_text = match output {
Value::String(s) => s,
other => other.to_string(),
};
let mut extra = serde_json::Map::new();
extra.insert("tool_call_id".into(), Value::String(call_id));
Some(ChatMessage {
role: "tool".into(),
content: MessageContent::Text(output),
content: MessageContent::Text(output_text),
extra: Value::Object(extra),
})
}
@@ -192,7 +217,9 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
.filter_map(|p| match p {
ResponsesContentPart::InputText { text }
| ResponsesContentPart::OutputText { text, .. } => Some(text),
ResponsesContentPart::InputImage { .. } => None,
ResponsesContentPart::InputImage { .. } | ResponsesContentPart::Unknown => {
None
}
})
.collect::<Vec<_>>()
.join("\n\n");
@@ -211,6 +238,7 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
"image_url": { "url": image_url },
}));
}
ResponsesContentPart::Unknown => {}
}
}
MessageContent::Parts(out)
@@ -535,6 +563,18 @@ mod tests {
use super::*;
use cortex_core::openai::MessageContent;
/// Wrap typed items as `input` elements. Most translator tests
/// exercise the typed path; the bare easy-message and unknown-item
/// paths have dedicated tests below.
fn typed_items(items: Vec<ResponsesInputItem>) -> ResponsesInput {
ResponsesInput::Items(
items
.into_iter()
.map(ResponsesInputElement::Typed)
.collect(),
)
}
fn meta() -> ResponseMeta {
ResponseMeta {
response_id: "resp_1".into(),
@@ -614,7 +654,7 @@ mod tests {
fn translates_input_items_to_chat_messages() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![
input: typed_items(vec![
ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Text("first".into()),
@@ -646,7 +686,7 @@ mod tests {
fn image_input_translates_to_chat_parts_array() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
input: typed_items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
@@ -687,7 +727,7 @@ mod tests {
// it's dropped — but it must not break translation.
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
input: typed_items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
@@ -729,7 +769,7 @@ mod tests {
fn text_only_parts_collapse_to_string() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
input: typed_items(vec![ResponsesInputItem::Message {
role: "user".into(),
content: ResponsesMessageContent::Parts(vec![
ResponsesContentPart::InputText {
@@ -759,7 +799,7 @@ mod tests {
fn reasoning_items_are_silently_dropped() {
let req = ResponsesRequest {
model: "m".into(),
input: ResponsesInput::Items(vec![
input: typed_items(vec![
ResponsesInputItem::Reasoning { content: vec![] },
ResponsesInputItem::Message {
role: "user".into(),
@@ -779,6 +819,74 @@ mod tests {
assert_eq!(chat.messages[0].role, "user");
}
#[test]
fn bare_easy_messages_translate_like_typed_messages() {
// The agent-zero / litellm shape: bare `{role, content}` items
// with no `type`. Deserialize from raw JSON (not hand-built)
// so this exercises the real parse path end to end.
let raw = r#"{
"model": "Qwen/Qwen3.6-27B",
"store": true,
"input": [
{"role": "system", "content": "be terse"},
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
{"role": "user", "content": "alpha"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let chat = request_to_chat(req).unwrap();
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["system", "assistant", "user"]);
assert!(matches!(
&chat.messages[2].content,
MessageContent::Text(t) if t == "alpha"
));
}
#[test]
fn null_content_and_unknown_items_survive_translation() {
// An assistant turn with `content: null` is kept (empty text);
// an unmodeled item type is dropped, not rejected.
let raw = r#"{
"model": "m",
"input": [
{"role": "assistant", "content": null},
{"type": "item_reference", "id": "x"},
{"role": "user", "content": "go"}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let chat = request_to_chat(req).unwrap();
// assistant(null) kept, item_reference dropped, user kept.
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["assistant", "user"]);
assert!(matches!(
&chat.messages[0].content,
MessageContent::Text(t) if t.is_empty()
));
}
#[test]
fn function_call_output_array_renders_to_text() {
// OpenAI allows `function_call_output.output` to be an array of
// content parts; the tool result must reach the model as text.
let raw = r#"{
"model": "m",
"input": [
{"type": "function_call_output", "call_id": "c1",
"output": [{"type": "output_text", "text": "42"}]}
]
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
let chat = request_to_chat(req).unwrap();
assert_eq!(chat.messages.len(), 1);
assert_eq!(chat.messages[0].role, "tool");
match &chat.messages[0].content {
MessageContent::Text(t) => assert!(t.contains("42"), "got {t:?}"),
other => panic!("expected text, got {other:?}"),
}
}
// ── streaming projector ─────────────────────────────────────────
async fn collect(mut rx: mpsc::Receiver<ResponseStreamFrame>) -> Vec<ResponseStreamFrame> {