Skip to content

Commit 287072e

Browse files
authored
Merge pull request #2108 from JslYoon/JslYoon-image-attachments
LCORE-2917 Adding image attachment capabilities to /query and /streaming_query
2 parents 6d006d2 + 7bce348 commit 287072e

14 files changed

Lines changed: 517 additions & 24 deletions

File tree

docs/devel_doc/openapi.json

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11650,23 +11650,26 @@
1165011650
"attachment_type": {
1165111651
"type": "string",
1165211652
"title": "Attachment Type",
11653-
"description": "The attachment type, like 'log', 'configuration' etc.",
11653+
"description": "The attachment type, like 'log', 'configuration', 'image' etc.",
1165411654
"examples": [
11655-
"log"
11655+
"log",
11656+
"image"
1165611657
]
1165711658
},
1165811659
"content_type": {
1165911660
"type": "string",
1166011661
"title": "Content Type",
1166111662
"description": "The content type as defined in MIME standard",
1166211663
"examples": [
11663-
"text/plain"
11664+
"text/plain",
11665+
"image/jpeg",
11666+
"image/png"
1166411667
]
1166511668
},
1166611669
"content": {
1166711670
"type": "string",
1166811671
"title": "Content",
11669-
"description": "The actual attachment content",
11672+
"description": "The actual attachment content (text or base64-encoded image data)",
1167011673
"examples": [
1167111674
"warning: quota exceeded"
1167211675
]
@@ -11680,7 +11683,7 @@
1168011683
"content"
1168111684
],
1168211685
"title": "Attachment",
11683-
"description": "Model representing an attachment that can be sent from the UI as part of query.\n\nA list of attachments can be an optional part of 'query' request.\n\nAttributes:\n attachment_type: The attachment type, like \"log\", \"configuration\" etc.\n content_type: The content type as defined in MIME standard\n content: The actual attachment content",
11686+
"description": "Model representing an attachment that can be sent from the UI as part of query.\n\nA list of attachments can be an optional part of 'query' request.\n\nAttributes:\n attachment_type: The attachment type, like \"log\", \"configuration\", \"image\" etc.\n content_type: The content type as defined in MIME standard\n content: The actual attachment content (text or base64-encoded image data)",
1168411687
"examples": [
1168511688
{
1168611689
"attachment_type": "log",
@@ -11696,6 +11699,11 @@
1169611699
"attachment_type": "configuration",
1169711700
"content": "foo: bar",
1169811701
"content_type": "application/yaml"
11702+
},
11703+
{
11704+
"attachment_type": "image",
11705+
"content": "<base64-encoded image data>",
11706+
"content_type": "image/png"
1169911707
}
1170011708
]
1170111709
},

src/app/endpoints/query.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from authorization.middleware import authorize
2424
from client import AsyncLlamaStackClientHolder
2525
from configuration import configuration
26-
from constants import ENDPOINT_PATH_QUERY
26+
from constants import ENDPOINT_PATH_QUERY, IMAGE_CONTENT_TYPES
2727
from log import get_logger
2828
from models.api.requests import QueryRequest
2929
from models.api.responses.constants import UNAUTHORIZED_OPENAPI_EXAMPLES_WITH_MCP_OAUTH
@@ -227,6 +227,13 @@ async def query_endpoint_handler(
227227
):
228228
client = await AsyncLlamaStackClientHolder().update_azure_token()
229229

230+
# Extract image attachments for multimodal support
231+
image_attachments = [
232+
a
233+
for a in (query_request.attachments or [])
234+
if a.content_type in IMAGE_CONTENT_TYPES
235+
] or None
236+
230237
# Retrieve response using Responses API
231238
turn_summary = await retrieve_agent_response(
232239
client,
@@ -235,6 +242,7 @@ async def query_endpoint_handler(
235242
endpoint_path,
236243
compaction.original_input if compaction.compacted else None,
237244
no_tools=bool(query_request.no_tools),
245+
image_attachments=image_attachments,
238246
)
239247

240248
if moderation_result.decision == "passed":

src/app/endpoints/streaming_query.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from configuration import configuration
4747
from constants import (
4848
ENDPOINT_PATH_STREAMING_QUERY,
49+
IMAGE_CONTENT_TYPES,
4950
LLM_TOKEN_EVENT,
5051
LLM_TOOL_CALL_EVENT,
5152
LLM_TOOL_RESULT_EVENT,
@@ -69,6 +70,7 @@
6970
UnprocessableEntityResponse,
7071
)
7172
from models.api.responses.successful import StreamingQueryResponse
73+
from models.common.query import Attachment
7274
from models.common.responses.contexts import ResponseGeneratorContext
7375
from models.common.responses.responses_api_params import ResponsesApiParams
7476
from models.common.responses.types import ResponseInput
@@ -307,6 +309,13 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
307309
)
308310
recording.record_llm_call(provider_id, model_id, endpoint_path)
309311

312+
# Extract image attachments for multimodal support
313+
image_attachments = [
314+
a
315+
for a in (query_request.attachments or [])
316+
if a.content_type in IMAGE_CONTENT_TYPES
317+
] or None
318+
310319
response_media_type = (
311320
MEDIA_TYPE_TEXT
312321
if query_request.media_type == MEDIA_TYPE_TEXT
@@ -330,6 +339,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
330339
context=context,
331340
responses_params=responses_params,
332341
endpoint_path=endpoint_path,
342+
image_attachments=image_attachments,
333343
),
334344
media_type=response_media_type,
335345
)
@@ -339,6 +349,7 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals
339349
context=context,
340350
endpoint_path=endpoint_path,
341351
no_tools=bool(query_request.no_tools),
352+
image_attachments=image_attachments,
342353
)
343354

344355
# Combine inline RAG results (BYOK + Solr) with tool-based results
@@ -461,6 +472,7 @@ async def generate_response_with_compaction(
461472
context: ResponseGeneratorContext,
462473
responses_params: ResponsesApiParams,
463474
endpoint_path: str,
475+
image_attachments: Optional[list[Attachment]] = None,
464476
) -> AsyncIterator[str]:
465477
"""Stream a response for a conversation that requires compaction.
466478
@@ -475,6 +487,7 @@ async def generate_response_with_compaction(
475487
context: The response generator context.
476488
responses_params: The base Responses API parameters.
477489
endpoint_path: API endpoint path used for metric labeling.
490+
image_attachments: Image attachments for multimodal prompt construction.
478491
479492
Yields:
480493
SSE-formatted strings.
@@ -507,6 +520,7 @@ async def generate_response_with_compaction(
507520
responses_params=responses_params,
508521
context=context,
509522
endpoint_path=endpoint_path,
523+
image_attachments=image_attachments,
510524
)
511525
except HTTPException as e:
512526
yield http_exception_stream_event(e)

src/constants.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,27 @@
3838
"configuration",
3939
"error message",
4040
"event",
41+
"image",
4142
"log",
4243
"stack trace",
4344
}
4445
)
4546

4647
# Supported attachment content types
4748
ATTACHMENT_CONTENT_TYPES: Final[frozenset[str]] = frozenset(
48-
{"text/plain", "application/json", "application/yaml", "application/xml"}
49+
{
50+
"text/plain",
51+
"application/json",
52+
"application/yaml",
53+
"application/xml",
54+
"image/jpeg",
55+
"image/png",
56+
}
4957
)
5058

59+
# Image content types (subset of ATTACHMENT_CONTENT_TYPES)
60+
IMAGE_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"image/jpeg", "image/png"})
61+
5162
# Default system prompt used only when no other system prompt is specified in
5263
# configuration file nor in the query request
5364
DEFAULT_SYSTEM_PROMPT: Final[str] = "You are a helpful assistant"

src/models/common/query.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""Shared query-related request primitives."""
22

3-
from typing import Any, Literal, Optional
3+
import base64
4+
import binascii
5+
from typing import Any, Literal, Optional, Self
46

57
from pydantic import BaseModel, ConfigDict, Field, model_validator
68

7-
from constants import SOLR_VECTOR_SEARCH_DEFAULT_MODE
9+
from constants import (
10+
DEFAULT_MAX_FILE_UPLOAD_SIZE,
11+
IMAGE_CONTENT_TYPES,
12+
SOLR_VECTOR_SEARCH_DEFAULT_MODE,
13+
)
814
from log import get_logger
915

1016
logger = get_logger(__name__)
@@ -16,24 +22,61 @@ class Attachment(BaseModel):
1622
A list of attachments can be an optional part of 'query' request.
1723
1824
Attributes:
19-
attachment_type: The attachment type, like "log", "configuration" etc.
25+
attachment_type: The attachment type, like "log", "configuration", "image" etc.
2026
content_type: The content type as defined in MIME standard
21-
content: The actual attachment content
27+
content: The actual attachment content (text or base64-encoded image data)
2228
"""
2329

2430
attachment_type: str = Field(
25-
description="The attachment type, like 'log', 'configuration' etc.",
26-
examples=["log"],
31+
description="The attachment type, like 'log', 'configuration', 'image' etc.",
32+
examples=["log", "image"],
2733
)
2834
content_type: str = Field(
2935
description="The content type as defined in MIME standard",
30-
examples=["text/plain"],
36+
examples=["text/plain", "image/jpeg", "image/png"],
3137
)
3238
content: str = Field(
33-
description="The actual attachment content",
39+
description="The actual attachment content (text or base64-encoded image data)",
3440
examples=["warning: quota exceeded"],
3541
)
3642

43+
@model_validator(mode="after")
44+
def validate_image_attachment(self) -> Self:
45+
"""Validate consistency between attachment_type and content_type for images.
46+
47+
Returns:
48+
Self: The validated Attachment instance.
49+
50+
Raises:
51+
ValueError: If attachment_type and content_type are inconsistent
52+
(one indicates an image while the other does not),
53+
if image content is not valid base64, or if decoded size exceeds the limit.
54+
"""
55+
is_image_content_type = self.content_type in IMAGE_CONTENT_TYPES
56+
is_image_attachment_type = self.attachment_type == "image"
57+
58+
if is_image_content_type != is_image_attachment_type:
59+
raise ValueError(
60+
f"attachment_type and content_type are inconsistent: "
61+
f"attachment_type='{self.attachment_type}', "
62+
f"content_type='{self.content_type}'"
63+
)
64+
65+
if is_image_content_type:
66+
try:
67+
decoded = base64.b64decode(self.content, validate=True)
68+
except (binascii.Error, ValueError) as exc:
69+
raise ValueError(
70+
f"Invalid base64 content for image attachment: {exc}"
71+
) from exc
72+
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
73+
raise ValueError(
74+
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
75+
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
76+
)
77+
78+
return self
79+
3780
# provides examples for /docs endpoint
3881
model_config = {
3982
"extra": "forbid",
@@ -54,6 +97,11 @@ class Attachment(BaseModel):
5497
"content_type": "application/yaml",
5598
"content": "foo: bar",
5699
},
100+
{
101+
"attachment_type": "image",
102+
"content_type": "image/png",
103+
"content": "<base64-encoded image data>",
104+
},
57105
]
58106
},
59107
}

src/utils/agents/query.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
)
3333
from models.common.agents import AgentTurnAccumulator
3434
from models.common.moderation import ShieldModerationResult
35+
from models.common.query import Attachment
3536
from models.common.responses.responses_api_params import ResponsesApiParams
3637
from models.common.responses.types import ResponseInput
3738
from models.common.turn_summary import TurnSummary
@@ -44,6 +45,7 @@
4445
from utils.conversations import append_turn_items_to_conversation
4546
from utils.pydantic_ai_helpers import build_agent
4647
from utils.query import (
48+
build_multimodal_input,
4749
extract_provider_and_model_from_model_id,
4850
handle_known_apistatus_errors,
4951
is_context_length_error,
@@ -287,6 +289,7 @@ async def retrieve_agent_response(
287289
endpoint_path: str,
288290
_original_input: Optional[ResponseInput] = None,
289291
no_tools: bool = False,
292+
image_attachments: Optional[list[Attachment]] = None,
290293
) -> TurnSummary:
291294
"""Retrieve a turn summary from a blocking agent run.
292295
@@ -299,6 +302,7 @@ async def retrieve_agent_response(
299302
endpoint_path: Endpoint path used for metric labeling.
300303
_original_input: Original user input before the explicit-input rewrite.
301304
no_tools: Whether to skip tool processing.
305+
image_attachments: Image attachments for multimodal prompt construction.
302306
Returns:
303307
Turn summary for the completed agent run.
304308
@@ -321,7 +325,14 @@ async def retrieve_agent_response(
321325
client, responses_params, configuration.skills, no_tools=no_tools
322326
)
323327
logger.debug("Starting agent non-streaming response processing")
324-
run_result = await agent.run(cast(str, responses_params.input))
328+
if image_attachments:
329+
prompt = build_multimodal_input(
330+
cast(str, responses_params.input),
331+
image_attachments,
332+
)
333+
else:
334+
prompt = cast(str, responses_params.input)
335+
run_result = await agent.run(prompt)
325336
except (AgentRunError, APIStatusError, APIConnectionError, RuntimeError) as exc:
326337
response = map_agent_inference_error(exc, responses_params.model)
327338
raise HTTPException(**response.model_dump()) from exc

0 commit comments

Comments
 (0)