Skip to content

Commit ae7c339

Browse files
committed
LCORE-2917: add multimodal image support for query endpoints
- prepare_input() skips image attachments (text-only for moderation) - build_multimodal_input() converts text + images into pydantic-ai UserContent parts with ImageUrl data URLs - ResponsesApiParams carries image_attachments (excluded from API body) - prepare_responses_params() extracts image attachments from request - Agent runners build multimodal prompts when images are present - Text-only requests are completely unchanged
1 parent b10bc5a commit ae7c339

6 files changed

Lines changed: 164 additions & 9 deletions

File tree

src/models/common/responses/responses_api_params.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from pydantic import BaseModel, Field
2222

23+
from models.common.query import Attachment
2324
from models.common.responses.types import IncludeParameter, InputTool, ResponseInput
2425
from utils.tool_formatter import translate_vector_store_ids_to_user_facing
2526

@@ -130,6 +131,12 @@ class ResponsesApiParams(BaseModel):
130131
"compacted, lightspeed-stack supplies explicit input and must not let "
131132
"Llama Stack reload the full history via the conversation parameter.",
132133
)
134+
image_attachments: Optional[list[Attachment]] = Field(
135+
default=None,
136+
exclude=True,
137+
description="Image attachments from the query request. Excluded from the "
138+
"API request body — used by agent runners to build multimodal prompts.",
139+
)
133140

134141
def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
135142
"""Serialize to a request body dict.

src/utils/agents/query.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from utils.conversations import append_turn_items_to_conversation
4545
from utils.pydantic_ai import build_agent
4646
from utils.query import (
47+
build_multimodal_input,
4748
extract_provider_and_model_from_model_id,
4849
handle_known_apistatus_errors,
4950
is_context_length_error,
@@ -318,7 +319,14 @@ async def retrieve_agent_response(
318319
client, responses_params, configuration.skills, no_tools=no_tools
319320
)
320321
logger.debug("Starting agent non-streaming response processing")
321-
run_result = await agent.run(cast(str, responses_params.input))
322+
if responses_params.image_attachments:
323+
prompt = build_multimodal_input(
324+
cast(str, responses_params.input),
325+
responses_params.image_attachments,
326+
)
327+
else:
328+
prompt = cast(str, responses_params.input)
329+
run_result = await agent.run(prompt)
322330
except (AgentRunError, APIStatusError, APIConnectionError, RuntimeError) as exc:
323331
response = map_agent_inference_error(exc, responses_params.model)
324332
raise HTTPException(**response.model_dump()) from exc

src/utils/agents/streaming.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@
5858
)
5959
from utils.conversations import append_turn_items_to_conversation
6060
from utils.pydantic_ai import build_agent
61-
from utils.query import consume_query_tokens, store_query_results
61+
from utils.query import (
62+
build_multimodal_input,
63+
consume_query_tokens,
64+
store_query_results,
65+
)
6266
from utils.quota_utils import get_available_quotas
6367
from utils.responses import (
6468
deduplicate_referenced_documents,
@@ -316,7 +320,13 @@ async def agent_response_generator(
316320
rag_id_mapping=context.rag_id_mapping,
317321
turn_summary=turn_summary,
318322
)
319-
prompt = cast(str, responses_params.input) # query is always a string
323+
if responses_params.image_attachments:
324+
prompt = build_multimodal_input(
325+
cast(str, responses_params.input),
326+
responses_params.image_attachments,
327+
)
328+
else:
329+
prompt = cast(str, responses_params.input)
320330

321331
logger.debug("Starting agent streaming response processing")
322332
async with agent.run_stream_events(prompt) as stream:

src/utils/query.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from llama_stack_client.types import Shield
1313
from openai._exceptions import APIStatusError as OpenAIAPIStatusError
14+
from pydantic_ai.messages import ImageUrl, UserContent
1415
from sqlalchemy import func
1516
from sqlalchemy.exc import SQLAlchemyError
1617

@@ -169,11 +170,11 @@ def is_input_shield(shield: Shield) -> bool:
169170
def prepare_input(
170171
query_request: QueryRequest, inline_rag_context: Optional[str] = None
171172
) -> str:
172-
"""
173-
Prepare input text for Responses API by appending RAG context and attachments.
173+
"""Prepare text input for moderation and Responses API.
174174
175175
Takes the query text, appends any inline RAG context for the LLM call, then
176-
appends any attachment content with type labels.
176+
appends any text attachment content with type labels. Image attachments are
177+
skipped — they are handled separately as structured multimodal input.
177178
178179
Args:
179180
query_request: The query request containing the query and optional attachments
@@ -182,20 +183,43 @@ def prepare_input(
182183
API model.
183184
184185
Returns:
185-
str: The input text with RAG context and attachments appended (if any)
186+
str: The input text with RAG context and text attachments appended (if any)
186187
"""
187188
input_text: str = query_request.query
188189
if inline_rag_context:
189190
input_text += f"\n\n{inline_rag_context}"
190191
if query_request.attachments:
191192
for attachment in query_request.attachments:
192-
# Append attachment content with type label
193+
if attachment.content_type in constants.IMAGE_CONTENT_TYPES:
194+
continue
193195
input_text += (
194196
f"\n\n[Attachment: {attachment.attachment_type}]\n{attachment.content}"
195197
)
196198
return input_text
197199

198200

201+
def build_multimodal_input(
202+
text: str, image_attachments: list[Attachment]
203+
) -> list[UserContent]:
204+
"""Build a pydantic-ai multimodal prompt from text and image attachments.
205+
206+
Constructs a list of UserContent items containing the text prompt followed
207+
by ImageUrl entries for each image attachment, using base64 data URLs.
208+
209+
Args:
210+
text: The text portion of the input (query + RAG context + text attachments).
211+
image_attachments: Image attachments with base64-encoded content.
212+
213+
Returns:
214+
List of UserContent items: the text string followed by ImageUrl objects.
215+
"""
216+
parts: list[UserContent] = [text]
217+
for attachment in image_attachments:
218+
data_url = f"data:{attachment.content_type};base64,{attachment.content}"
219+
parts.append(ImageUrl(url=data_url, media_type=attachment.content_type))
220+
return parts
221+
222+
199223
def store_query_results( # pylint: disable=too-many-arguments
200224
user_id: str,
201225
conversation_id: str,

src/utils/responses.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
NotFoundResponse,
9494
ServiceUnavailableResponse,
9595
)
96+
from models.common.query import Attachment
9697
from models.common.responses.responses_api_params import ResponsesApiParams
9798
from models.common.responses.types import (
9899
InputTool,
@@ -384,9 +385,20 @@ async def prepare_responses_params( # pylint: disable=too-many-arguments,too-ma
384385
)
385386

386387
# Prepare input for Responses API
387-
# Adds inline RAG context and attachments
388+
# Adds inline RAG context and text attachments (images are excluded)
388389
input_text = prepare_input(query_request, inline_rag_context)
389390

391+
# Extract image attachments for multimodal support
392+
image_attachments: Optional[list[Attachment]] = None
393+
if query_request.attachments:
394+
images = [
395+
a
396+
for a in query_request.attachments
397+
if a.content_type in constants.IMAGE_CONTENT_TYPES
398+
]
399+
if images:
400+
image_attachments = images
401+
390402
# Handle conversation ID for Responses API
391403
conversation_id = query_request.conversation_id
392404
if conversation_id:
@@ -431,6 +443,7 @@ async def prepare_responses_params( # pylint: disable=too-many-arguments,too-ma
431443
extra_headers=extra_headers,
432444
max_infer_iters=configuration.inference.max_infer_iters,
433445
max_tool_calls=configuration.inference.max_tool_calls,
446+
image_attachments=image_attachments,
434447
)
435448

436449

tests/unit/utils/test_query.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
# pylint: disable=too-many-lines
44

5+
import base64
56
import sqlite3
67
from typing import Any
78

89
import psycopg2
910
import pytest
1011
from fastapi import HTTPException
1112
from llama_stack_client.types import ModelListResponse
13+
from pydantic_ai.messages import ImageUrl
1214
from pytest_mock import MockerFixture
1315
from sqlalchemy.exc import SQLAlchemyError
1416

@@ -27,6 +29,7 @@
2729
from models.database.conversations import UserConversation, UserTurn
2830
from tests.unit import config_dict
2931
from utils.query import (
32+
build_multimodal_input,
3033
consume_query_tokens,
3134
extract_provider_and_model_from_model_id,
3235
handle_known_apistatus_errors,
@@ -255,6 +258,96 @@ def test_prepare_input_with_attachments(self) -> None:
255258
assert "[Attachment: text]" in result
256259
assert "attachment content" in result
257260

261+
def test_prepare_input_skips_image_attachments(self) -> None:
262+
"""Test that image attachments are excluded from text input."""
263+
text_attachment = Attachment(
264+
attachment_type="log",
265+
content="log output",
266+
content_type="text/plain",
267+
)
268+
image_attachment = Attachment(
269+
attachment_type="image",
270+
content=base64.b64encode(b"\xff\xd8\xff\xe0" + b"\x00" * 10).decode(),
271+
content_type="image/jpeg",
272+
)
273+
query_request = QueryRequest(
274+
query="describe this image",
275+
attachments=[text_attachment, image_attachment],
276+
) # pyright: ignore[reportCallIssue]
277+
result = prepare_input(query_request)
278+
assert "describe this image" in result
279+
assert "[Attachment: log]" in result
280+
assert "log output" in result
281+
assert result.count("[Attachment:") == 1
282+
283+
def test_prepare_input_only_image_attachments(self) -> None:
284+
"""Test prepare_input with only image attachments returns just the query."""
285+
image_attachment = Attachment(
286+
attachment_type="image",
287+
content=base64.b64encode(b"\x89PNG" + b"\x00" * 10).decode(),
288+
content_type="image/png",
289+
)
290+
query_request = QueryRequest(
291+
query="what is in this image",
292+
attachments=[image_attachment],
293+
) # pyright: ignore[reportCallIssue]
294+
result = prepare_input(query_request)
295+
assert result == "what is in this image"
296+
297+
298+
class TestBuildMultimodalInput:
299+
"""Tests for build_multimodal_input function."""
300+
301+
def test_single_image(self) -> None:
302+
"""Test building multimodal input with one image."""
303+
image_data = base64.b64encode(b"\xff\xd8\xff\xe0" + b"\x00" * 10).decode()
304+
image_attachment = Attachment(
305+
attachment_type="image",
306+
content=image_data,
307+
content_type="image/jpeg",
308+
)
309+
result = build_multimodal_input("describe this", [image_attachment])
310+
assert len(result) == 2
311+
assert result[0] == "describe this"
312+
assert isinstance(result[1], ImageUrl)
313+
assert result[1].url == f"data:image/jpeg;base64,{image_data}"
314+
315+
def test_multiple_images(self) -> None:
316+
"""Test building multimodal input with multiple images."""
317+
jpeg_data = base64.b64encode(b"\xff\xd8\xff\xe0" + b"\x00" * 10).decode()
318+
png_data = base64.b64encode(b"\x89PNG" + b"\x00" * 10).decode()
319+
attachments = [
320+
Attachment(
321+
attachment_type="image",
322+
content=jpeg_data,
323+
content_type="image/jpeg",
324+
),
325+
Attachment(
326+
attachment_type="image",
327+
content=png_data,
328+
content_type="image/png",
329+
),
330+
]
331+
result = build_multimodal_input("compare these", attachments)
332+
assert len(result) == 3
333+
assert result[0] == "compare these"
334+
assert isinstance(result[1], ImageUrl)
335+
assert result[1].url == f"data:image/jpeg;base64,{jpeg_data}"
336+
assert isinstance(result[2], ImageUrl)
337+
assert result[2].url == f"data:image/png;base64,{png_data}"
338+
339+
def test_image_url_media_type(self) -> None:
340+
"""Test that ImageUrl has correct media_type set."""
341+
image_data = base64.b64encode(b"\x89PNG" + b"\x00" * 10).decode()
342+
attachment = Attachment(
343+
attachment_type="image",
344+
content=image_data,
345+
content_type="image/png",
346+
)
347+
result = build_multimodal_input("test", [attachment])
348+
assert isinstance(result[1], ImageUrl)
349+
assert result[1].media_type == "image/png"
350+
258351

259352
class TestExtractProviderAndModelFromModelId:
260353
"""Tests for extract_provider_and_model_from_model_id function."""

0 commit comments

Comments
 (0)