Repository navigation
Description
Summary
/v1/responses reuses the chat-completions content-part types, so a request written against the Responses API's own part vocabulary (input_text, input_image with a string image_url, input_file) fails to deserialize, and function_call_output.output only accepts a string, so a tool that returns a screenshot (output: [{"type":"input_image","image_url":"data:..."}]) is rejected before any model work. The change adds the native part types to the Responses request model, maps them onto the chat-completions parts the renderer already understands, and routes image parts inside a tool output into the prompt as a follow-up user image turn so the model actually sees them.
Current behavior
-
src/server/types/responses_request.rs:265:ResponseInputContent::Parts(Vec<ContentPart>)whereContentPart(src/server/types/request.rs:147) is the chat-completions enum with variantstext,image_url,video_url,input_audio. A part taggedinput_textorinput_imageis a serde error, surfaced as a 400 before translation. -
src/server/types/responses_request.rs:235:FunctionCallOutput { call_id: String, output: String },
An array-valued
outputis a deserialization error. -
src/server/responses_translator.rs:358builds the tool turn asMessageContent::Text(output.clone()); there is no path for media inside a tool result. -
docs/responses-api.md:107states the limitation: "OpenAI Responses-specificinput_image/input_filecompatibility is not complete." -
The render and media pipeline downstream is already shape-agnostic:
routes/responses.rs:253passesprepared.mediafromprepare_chat_request_with_cache, which collects everyContentPart::ImageUrlacross the message list in order (ChatCompletionRequest::image_urls).
Expected behavior
Accepted input part shapes on message items and inside function_call_output.output arrays:
| part | fields | mapped to |
|---|---|---|
{"type":"input_text","text":"..."} |
text: String |
ContentPart::Text { text } |
| `{"type":"input_image","image_url":"","detail":"auto | low | high"}` |
{"type":"input_image","file_id":"..."} (no image_url) |
400 invalid_request_error: "input_image.file_id is not supported by this server. Provide image_url instead." |
|
{"type":"input_file", ...} |
400 invalid_request_error naming the part type |
|
| `{"type":"text" | "image_url" | "video_url" |
function_call_output.output accepts a string (unchanged) or an array of the parts above. Translation of an array output:
- Text parts are joined with
"\n"into thetoolmessage content. - Parts that are neither text nor image are JSON-serialised (
serde_json::to_stringof the array of leftovers) and appended as one more text line, so nothing is silently dropped. - If the array held N image parts, the line
[Image output attached in the next message]is appended to the tool message, and a new message{"role":"user","content":[N x ContentPart::ImageUrl]}is pushed immediately after the tool message. The image order inside the follow-up turn is the order insideoutput.
Images on a message item with role user stay on that turn as content parts (no reordering). Images on an assistant, system or developer message item are moved to a following user turn the same way as tool outputs, because chat templates render image placeholders only inside user turns.
Stored responses (previous_response_id / conversation) replay the translated items, so a tool output that carried images is reproduced identically when the conversation continues.
Implementation plan
src/server/types/responses_request.rs:- Add
#[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputPart { InputText { text: String }, InputImage { #[serde(default)] image_url: Option<String>, #[serde(default)] detail: Option<String>, #[serde(default)] file_id: Option<String> }, InputFile { #[serde(flatten)] raw: serde_json::Map<String, serde_json::Value> }, Text { text: String }, ImageUrl { image_url: ImageUrl }, VideoUrl { video_url: VideoUrl }, InputAudio { input_audio: InputAudio } }. The last four mirror the chat-completionsContentPartvariants one to one so existing clients keep working; addimpl TryFrom<ResponseInputPart> for ContentPartthat performs the mapping in the table (and returns the 400 messages forfile_id-only andinput_file). - Change
ResponseInputContent::Parts(Vec<ContentPart>)toParts(Vec<ResponseInputPart>)and updateas_textto read bothInputTextandText. - Change
FunctionCallOutput { call_id: String, output: ResponseToolOutput }with#[serde(untagged)] pub enum ResponseToolOutput { Text(String), Parts(Vec<ResponseInputPart>) }.
- Add
src/server/responses_translator.rs:- Add
fn lower_parts(parts: &[ResponseInputPart]) -> Result<(Vec<ContentPart>, Vec<ImageUrl>), String>that mapsInputText/InputImageper the table, returns the 400 message forfile_id-only images andinput_file, and collects the images in order. - In
input_items_to_messages(src/server/responses_translator.rs:305onward), forMessageitems: if role isUser, keep the lowered parts asMessageContent::Parts; otherwise emit the text asMessageContent::Textand push the image follow-up user turnimage_followup_message(images)right after. - For
FunctionCallOutput, replace the singleMessageContent::Text(output.clone())with the three-step lowering above; push the follow-up user turn after the tool message. Keep the existingpending_reasoningclearing in that arm. input_items_to_messagesbecomes fallible (Result<Vec<Message>, String>); the two callers intranslate_requestreturn the message as a 400invalid_request_errorthrough the existing error path inroutes/responses.rs.
- Add
src/server/responses_store.rs/conversation_store.rs: the canonical stored input items are the rawResponseInputItems (canonical_input_itemsatsrc/server/responses_translator.rs:175), so storage needs no schema change; confirm replay round-trips through the new enum with a unit test.src/server/routes/responses.rs:101(empty-input rejection): countInputImageparts and image parts inside tool outputs as effective input so an image-only request is not rejected as empty.docs/responses-api.md: replace the "not complete" sentence with the table above and the image-follow-up rule for tool outputs.
Validation
(a) Unit tests in src/server/responses_translator.rs tests module and src/server/types/responses_request.rs tests module:
input_text_and_input_image_parts_lower_to_chat_parts(user message withinput_text+input_imagedata URI yieldsMessageContent::PartswithTextthenImageUrl,detailcarried).input_image_file_id_only_is_rejected_with_named_error.input_file_part_is_rejected.function_output_image_stays_after_tool_result(tool output[input_text "done", input_image]yields a tool message whose text ends with[Image output attached in the next message]followed by a user message with exactly oneImageUrlpart; the rendered prompt has the tool text before the image placeholder).function_output_preserves_text_alongside_visual_input.function_output_string_form_is_unchanged.unknown_function_output_parts_remain_as_json_text(a part of typefoois serialised into the tool text, images list empty).assistant_message_image_moves_to_following_user_turn.stored_response_with_image_tool_output_replays_identically(round-trip throughresponses_store).
(b) Real checkpoint: mlx-community/Qwen3-VL-4B-Instruct-4bit (image-capable, tool-capable).
./target/release/mlxcel-server -m models/qwen3-vl-4b-instruct-4bit --port 8080
IMG=$(base64 -i tests/fixtures/red_square.png) # any small image
curl -s localhost:8080/v1/responses -H 'content-type: application/json' -d "{
\"model\":\"q\",
\"input\":[
{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Take a screenshot and tell me its dominant color.\"}]},
{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"screenshot\",\"arguments\":\"{}\"},
{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":[{\"type\":\"input_text\",\"text\":\"captured\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,$IMG\"}]}
],
\"tools\":[{\"type\":\"function\",\"name\":\"screenshot\",\"parameters\":{\"type\":\"object\",\"properties\":{}}}]
}" | jq -r '.output[] | select(.type=="message") | .content[0].text'Acceptance: HTTP 200, the answer names the image's dominant colour (red for a red square), and the server log shows one image in the prepared media for the request. A second request with {"type":"input_image","file_id":"f1"} returns HTTP 400 with the named message. A user message carrying input_image directly produces the same answer as the equivalent image_url chat-completions request.
Acceptance criteria
-
input_textandinput_imageparts deserialize on message items and insidefunction_call_output.outputarrays. -
file_id-only images andinput_fileparts return a named 400. - Image parts in tool outputs and non-user messages reach the model as a following user image turn; user-turn images keep their position.
- Stored-response replay preserves the lowered messages.
-
docs/responses-api.mdupdated. - Unit tests above added; real-checkpoint check above passes.
- cargo test --workspace --profile test-fast --features metal,accelerate passes
- cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all -- --check pass
Out of scope
input_filesupport (PDF / document ingestion).file_idresolution against an uploads store.- Merging multiple
system/developeritems into one leading system turn.
Activity
feat: accept Responses-native image input parts (lablup#1310) (lablup…