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
18 changes: 13 additions & 5 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -11487,23 +11487,26 @@
"attachment_type": {
"type": "string",
"title": "Attachment Type",
"description": "The attachment type, like 'log', 'configuration' etc.",
"description": "The attachment type, like 'log', 'configuration', 'image' etc.",
"examples": [
"log"
"log",
"image"
]
},
"content_type": {
"type": "string",
"title": "Content Type",
"description": "The content type as defined in MIME standard",
"examples": [
"text/plain"
"text/plain",
"image/jpeg",
"image/png"
]
},
"content": {
"type": "string",
"title": "Content",
"description": "The actual attachment content",
"description": "The actual attachment content (text or base64-encoded image data)",
"examples": [
"warning: quota exceeded"
]
Expand All @@ -11517,7 +11520,7 @@
"content"
],
"title": "Attachment",
"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",
"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)",
"examples": [
{
"attachment_type": "log",
Expand All @@ -11533,6 +11536,11 @@
"attachment_type": "configuration",
"content": "foo: bar",
"content_type": "application/yaml"
},
{
"attachment_type": "image",
"content": "<base64-encoded image data>",
"content_type": "image/png"
}
]
},
Expand Down
13 changes: 12 additions & 1 deletion src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,27 @@
"configuration",
"error message",
"event",
"image",
"log",
"stack trace",
}
)

# Supported attachment content types
ATTACHMENT_CONTENT_TYPES: Final[frozenset[str]] = frozenset(
{"text/plain", "application/json", "application/yaml", "application/xml"}
{
"text/plain",
"application/json",
"application/yaml",
"application/xml",
"image/jpeg",
"image/png",
}
)

# Image content types (subset of ATTACHMENT_CONTENT_TYPES)
IMAGE_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"image/jpeg", "image/png"})

# Default system prompt used only when no other system prompt is specified in
# configuration file nor in the query request
DEFAULT_SYSTEM_PROMPT: Final[str] = "You are a helpful assistant"
Expand Down
64 changes: 57 additions & 7 deletions src/models/common/query.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"""Shared query-related request primitives."""

import base64
import binascii
from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field, model_validator

from constants import SOLR_VECTOR_SEARCH_DEFAULT_MODE
from constants import (
DEFAULT_MAX_FILE_UPLOAD_SIZE,
IMAGE_CONTENT_TYPES,
SOLR_VECTOR_SEARCH_DEFAULT_MODE,
)
from log import get_logger

logger = get_logger(__name__)
Expand All @@ -16,24 +22,63 @@ class Attachment(BaseModel):
A list of attachments can be an optional part of 'query' request.

Attributes:
attachment_type: The attachment type, like "log", "configuration" etc.
attachment_type: The attachment type, like "log", "configuration", "image" etc.
content_type: The content type as defined in MIME standard
content: The actual attachment content
content: The actual attachment content (text or base64-encoded image data)
"""

attachment_type: str = Field(
description="The attachment type, like 'log', 'configuration' etc.",
examples=["log"],
description="The attachment type, like 'log', 'configuration', 'image' etc.",
examples=["log", "image"],
)
content_type: str = Field(
description="The content type as defined in MIME standard",
examples=["text/plain"],
examples=["text/plain", "image/jpeg", "image/png"],
)
content: str = Field(
description="The actual attachment content",
description="The actual attachment content (text or base64-encoded image data)",
examples=["warning: quota exceeded"],
)

@model_validator(mode="after")
def validate_image_attachment(self) -> "Attachment":
"""Validate consistency between attachment_type and content_type for images.

Raises:
ValueError: If image content_type is used without attachment_type='image',
if attachment_type='image' is used without an image content_type,
if image content is not valid base64, or if decoded size exceeds the limit.
"""
is_image_content_type = self.content_type in IMAGE_CONTENT_TYPES
is_image_attachment_type = self.attachment_type == "image"

if is_image_content_type and not is_image_attachment_type:
raise ValueError(
f"attachment_type must be 'image' when content_type is "
f"'{self.content_type}'"
)

if is_image_attachment_type and not is_image_content_type:
raise ValueError(
f"content_type must be 'image/jpeg' or 'image/png' when "
f"attachment_type is 'image', got '{self.content_type}'"
)

if is_image_content_type:
try:
decoded = base64.b64decode(self.content, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(
f"Invalid base64 content for image attachment: {exc}"
) from exc
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
raise ValueError(
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
)
Comment on lines +67 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider checking base64 string length before decoding to avoid oversized allocations.

The size check on line 74 runs after base64.b64decode has already allocated the full decoded buffer in memory. A payload just over the limit is fully decoded before rejection. Estimating the decoded size from the base64 string length (len(content) * 3 // 4) before decoding would reject oversized payloads earlier and reduce memory pressure from malicious requests.

🛡️ Proposed pre-decode size guard
         if is_image_content_type:
+            # Estimate decoded size before allocating the full buffer
+            estimated_size = len(self.content) * 3 // 4
+            if estimated_size > DEFAULT_MAX_FILE_UPLOAD_SIZE:
+                raise ValueError(
+                    f"Image attachment (~{estimated_size} bytes) exceeds maximum "
+                    f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
+                )
             try:
                 decoded = base64.b64decode(self.content, validate=True)
             except (binascii.Error, ValueError) as exc:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if is_image_content_type:
try:
decoded = base64.b64decode(self.content, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(
f"Invalid base64 content for image attachment: {exc}"
) from exc
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
raise ValueError(
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
)
if is_image_content_type:
# Estimate decoded size before allocating the full buffer
estimated_size = len(self.content) * 3 // 4
if estimated_size > DEFAULT_MAX_FILE_UPLOAD_SIZE:
raise ValueError(
f"Image attachment (~{estimated_size} bytes) exceeds maximum "
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
)
try:
decoded = base64.b64decode(self.content, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(
f"Invalid base64 content for image attachment: {exc}"
) from exc
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
raise ValueError(
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/models/common/query.py` around lines 67 - 78, In the image branch of the
content validation method, add a pre-decode size guard using the base64 payload
length (for example, len(self.content) * 3 // 4) and raise the same size-limit
ValueError before calling base64.b64decode; retain the existing decoded-size
check afterward to handle padding and malformed inputs accurately.


return self

Comment on lines +43 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add "Returns:" section to the validator docstring.

Per coding guidelines, Google Python docstring conventions require a Returns: section. The docstring currently only documents Raises:. As per learnings, the project uses "Parameters:" (not "Args:") and follows Google conventions with required sections.

📝 Proposed docstring fix
     """Validate consistency between attachment_type and content_type for images.

+    Returns:
+        The validated Attachment instance.
+
     Raises:
         ValueError: If image content_type is used without attachment_type='image',
             if attachment_type='image' is used without an image content_type,
             if image content is not valid base64, or if decoded size exceeds the limit.
     """
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@model_validator(mode="after")
def validate_image_attachment(self) -> "Attachment":
"""Validate consistency between attachment_type and content_type for images.
Raises:
ValueError: If image content_type is used without attachment_type='image',
if attachment_type='image' is used without an image content_type,
if image content is not valid base64, or if decoded size exceeds the limit.
"""
is_image_content_type = self.content_type in IMAGE_CONTENT_TYPES
is_image_attachment_type = self.attachment_type == "image"
if is_image_content_type and not is_image_attachment_type:
raise ValueError(
f"attachment_type must be 'image' when content_type is "
f"'{self.content_type}'"
)
if is_image_attachment_type and not is_image_content_type:
raise ValueError(
f"content_type must be 'image/jpeg' or 'image/png' when "
f"attachment_type is 'image', got '{self.content_type}'"
)
if is_image_content_type:
try:
decoded = base64.b64decode(self.content, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(
f"Invalid base64 content for image attachment: {exc}"
) from exc
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
raise ValueError(
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
)
return self
`@model_validator`(mode="after")
def validate_image_attachment(self) -> "Attachment":
"""Validate consistency between attachment_type and content_type for images.
Returns:
The validated Attachment instance.
Raises:
ValueError: If image content_type is used without attachment_type='image',
if attachment_type='image' is used without an image content_type,
if image content is not valid base64, or if decoded size exceeds the limit.
"""
is_image_content_type = self.content_type in IMAGE_CONTENT_TYPES
is_image_attachment_type = self.attachment_type == "image"
if is_image_content_type and not is_image_attachment_type:
raise ValueError(
f"attachment_type must be 'image' when content_type is "
f"'{self.content_type}'"
)
if is_image_attachment_type and not is_image_content_type:
raise ValueError(
f"content_type must be 'image/jpeg' or 'image/png' when "
f"attachment_type is 'image', got '{self.content_type}'"
)
if is_image_content_type:
try:
decoded = base64.b64decode(self.content, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(
f"Invalid base64 content for image attachment: {exc}"
) from exc
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
raise ValueError(
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
)
return self
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/models/common/query.py` around lines 43 - 81, Add a Google-style
“Returns:” section to the validate_image_attachment docstring, documenting that
it returns the validated Attachment instance. Keep the existing “Raises:”
section and follow the project’s “Parameters:”/Google docstring conventions.

Sources: Coding guidelines, Learnings

# provides examples for /docs endpoint
model_config = {
"extra": "forbid",
Expand All @@ -54,6 +99,11 @@ class Attachment(BaseModel):
"content_type": "application/yaml",
"content": "foo: bar",
},
{
"attachment_type": "image",
"content_type": "image/png",
"content": "<base64-encoded image data>",
},
]
},
}
Expand Down
7 changes: 7 additions & 0 deletions src/models/common/responses/responses_api_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from pydantic import BaseModel, Field

from models.common.query import Attachment
from models.common.responses.types import IncludeParameter, InputTool, ResponseInput
from utils.tool_formatter import translate_vector_store_ids_to_user_facing

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

def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""Serialize to a request body dict.
Expand Down
10 changes: 9 additions & 1 deletion src/utils/agents/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from utils.conversations import append_turn_items_to_conversation
from utils.pydantic_ai import build_agent
from utils.query import (
build_multimodal_input,
extract_provider_and_model_from_model_id,
handle_known_apistatus_errors,
is_context_length_error,
Expand Down Expand Up @@ -318,7 +319,14 @@ async def retrieve_agent_response(
client, responses_params, configuration.skills, no_tools=no_tools
)
logger.debug("Starting agent non-streaming response processing")
run_result = await agent.run(cast(str, responses_params.input))
if responses_params.image_attachments:
prompt = build_multimodal_input(
cast(str, responses_params.input),
responses_params.image_attachments,
)
else:
prompt = cast(str, responses_params.input)
run_result = await agent.run(prompt)
Comment on lines +322 to +329

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract duplicated multimodal prompt-building logic into a helper.

The same if/else pattern for building a multimodal vs text prompt is duplicated in retrieve_agent_response (lines 322-329) and agent_response_generator in src/utils/agents/streaming.py (lines 323-329). Extracting a helper would eliminate the duplication and ensure both paths stay consistent if the logic evolves (e.g., adding video attachments).

♻️ Proposed helper in src/utils/query.py
def build_prompt_from_params(
    responses_params: ResponsesApiParams,
) -> str | list[UserContent]:
    """Build a text or multimodal prompt from Responses API parameters.

    Parameters:
        responses_params: Prepared Responses API parameters containing input
            text and optional image attachments.

    Returns:
        A multimodal prompt list if image attachments are present, otherwise
        the input text as a string.
    """
    if responses_params.image_attachments:
        return build_multimodal_input(
            cast(str, responses_params.input),
            responses_params.image_attachments,
        )
    return cast(str, responses_params.input)

Then in both retrieve_agent_response and agent_response_generator:

-        if responses_params.image_attachments:
-            prompt = build_multimodal_input(
-                cast(str, responses_params.input),
-                responses_params.image_attachments,
-            )
-        else:
-            prompt = cast(str, responses_params.input)
+        prompt = build_prompt_from_params(responses_params)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/agents/query.py` around lines 322 - 329, Extract the duplicated
prompt-selection logic into a shared build_prompt_from_params helper in
src/utils/query.py, preserving the existing multimodal-input and text fallback
behavior. Update retrieve_agent_response and agent_response_generator to call
this helper, and add the necessary imports while removing their local if/else
blocks.

except (AgentRunError, APIStatusError, APIConnectionError, RuntimeError) as exc:
response = map_agent_inference_error(exc, responses_params.model)
raise HTTPException(**response.model_dump()) from exc
Expand Down
14 changes: 12 additions & 2 deletions src/utils/agents/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@
)
from utils.conversations import append_turn_items_to_conversation
from utils.pydantic_ai import build_agent
from utils.query import consume_query_tokens, store_query_results
from utils.query import (
build_multimodal_input,
consume_query_tokens,
store_query_results,
)
from utils.quota_utils import get_available_quotas
from utils.responses import (
deduplicate_referenced_documents,
Expand Down Expand Up @@ -316,7 +320,13 @@ async def agent_response_generator(
rag_id_mapping=context.rag_id_mapping,
turn_summary=turn_summary,
)
prompt = cast(str, responses_params.input) # query is always a string
if responses_params.image_attachments:
prompt = build_multimodal_input(
cast(str, responses_params.input),
responses_params.image_attachments,
)
else:
prompt = cast(str, responses_params.input)

logger.debug("Starting agent streaming response processing")
async with agent.run_stream_events(prompt) as stream:
Expand Down
34 changes: 29 additions & 5 deletions src/utils/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
)
from llama_stack_client.types import Shield
from openai._exceptions import APIStatusError as OpenAIAPIStatusError
from pydantic_ai.messages import ImageUrl, UserContent
from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError

Expand Down Expand Up @@ -169,11 +170,11 @@ def is_input_shield(shield: Shield) -> bool:
def prepare_input(
query_request: QueryRequest, inline_rag_context: Optional[str] = None
) -> str:
"""
Prepare input text for Responses API by appending RAG context and attachments.
"""Prepare text input for moderation and Responses API.

Takes the query text, appends any inline RAG context for the LLM call, then
appends any attachment content with type labels.
appends any text attachment content with type labels. Image attachments are
skipped — they are handled separately as structured multimodal input.

Args:
query_request: The query request containing the query and optional attachments
Expand All @@ -182,20 +183,43 @@ def prepare_input(
API model.

Returns:
str: The input text with RAG context and attachments appended (if any)
str: The input text with RAG context and text attachments appended (if any)
"""
input_text: str = query_request.query
if inline_rag_context:
input_text += f"\n\n{inline_rag_context}"
if query_request.attachments:
for attachment in query_request.attachments:
# Append attachment content with type label
if attachment.content_type in constants.IMAGE_CONTENT_TYPES:
continue
input_text += (
f"\n\n[Attachment: {attachment.attachment_type}]\n{attachment.content}"
)
return input_text


def build_multimodal_input(
text: str, image_attachments: list[Attachment]
) -> list[UserContent]:
"""Build a pydantic-ai multimodal prompt from text and image attachments.

Constructs a list of UserContent items containing the text prompt followed
by ImageUrl entries for each image attachment, using base64 data URLs.

Args:
text: The text portion of the input (query + RAG context + text attachments).
image_attachments: Image attachments with base64-encoded content.

Returns:
List of UserContent items: the text string followed by ImageUrl objects.
"""
parts: list[UserContent] = [text]
for attachment in image_attachments:
data_url = f"data:{attachment.content_type};base64,{attachment.content}"
parts.append(ImageUrl(url=data_url, media_type=attachment.content_type))
return parts


def store_query_results( # pylint: disable=too-many-arguments
user_id: str,
conversation_id: str,
Expand Down
15 changes: 14 additions & 1 deletion src/utils/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
NotFoundResponse,
ServiceUnavailableResponse,
)
from models.common.query import Attachment
from models.common.responses.responses_api_params import ResponsesApiParams
from models.common.responses.types import (
InputTool,
Expand Down Expand Up @@ -384,9 +385,20 @@ async def prepare_responses_params( # pylint: disable=too-many-arguments,too-ma
)

# Prepare input for Responses API
# Adds inline RAG context and attachments
# Adds inline RAG context and text attachments (images are excluded)
input_text = prepare_input(query_request, inline_rag_context)

# Extract image attachments for multimodal support
image_attachments: Optional[list[Attachment]] = None
if query_request.attachments:
images = [
a
for a in query_request.attachments
if a.content_type in constants.IMAGE_CONTENT_TYPES
]
if images:
image_attachments = images

# Handle conversation ID for Responses API
conversation_id = query_request.conversation_id
if conversation_id:
Expand Down Expand Up @@ -431,6 +443,7 @@ async def prepare_responses_params( # pylint: disable=too-many-arguments,too-ma
extra_headers=extra_headers,
max_infer_iters=configuration.inference.max_infer_iters,
max_tool_calls=configuration.inference.max_tool_calls,
image_attachments=image_attachments,
)


Expand Down
Loading
Loading