Skip to content

Commit b10bc5a

Browse files
committed
LCORE-2917: extend Attachment model with image support
Add 'image' attachment type with 'image/jpeg' and 'image/png' content types. Validate base64 encoding and enforce size limits for image attachments.
1 parent ae6ecf3 commit b10bc5a

3 files changed

Lines changed: 146 additions & 8 deletions

File tree

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: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""Shared query-related request primitives."""
22

3+
import base64
4+
import binascii
35
from typing import Any, Literal, Optional
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,63 @@ 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) -> "Attachment":
45+
"""Validate consistency between attachment_type and content_type for images.
46+
47+
Raises:
48+
ValueError: If image content_type is used without attachment_type='image',
49+
if attachment_type='image' is used without an image content_type,
50+
if image content is not valid base64, or if decoded size exceeds the limit.
51+
"""
52+
is_image_content_type = self.content_type in IMAGE_CONTENT_TYPES
53+
is_image_attachment_type = self.attachment_type == "image"
54+
55+
if is_image_content_type and not is_image_attachment_type:
56+
raise ValueError(
57+
f"attachment_type must be 'image' when content_type is "
58+
f"'{self.content_type}'"
59+
)
60+
61+
if is_image_attachment_type and not is_image_content_type:
62+
raise ValueError(
63+
f"content_type must be 'image/jpeg' or 'image/png' when "
64+
f"attachment_type is 'image', got '{self.content_type}'"
65+
)
66+
67+
if is_image_content_type:
68+
try:
69+
decoded = base64.b64decode(self.content, validate=True)
70+
except (binascii.Error, ValueError) as exc:
71+
raise ValueError(
72+
f"Invalid base64 content for image attachment: {exc}"
73+
) from exc
74+
if len(decoded) > DEFAULT_MAX_FILE_UPLOAD_SIZE:
75+
raise ValueError(
76+
f"Image attachment ({len(decoded)} bytes) exceeds maximum "
77+
f"allowed size ({DEFAULT_MAX_FILE_UPLOAD_SIZE} bytes)"
78+
)
79+
80+
return self
81+
3782
# provides examples for /docs endpoint
3883
model_config = {
3984
"extra": "forbid",
@@ -54,6 +99,11 @@ class Attachment(BaseModel):
5499
"content_type": "application/yaml",
55100
"content": "foo: bar",
56101
},
102+
{
103+
"attachment_type": "image",
104+
"content_type": "image/png",
105+
"content": "<base64-encoded image data>",
106+
},
57107
]
58108
},
59109
}

tests/unit/models/requests/test_attachment.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Unit tests for Attachment model."""
22

3+
import base64
4+
5+
import pytest
6+
from pydantic import ValidationError
7+
38
from models.common.query import Attachment
49

510

@@ -36,3 +41,75 @@ def test_constructor_unknown_attachment_type(self) -> None:
3641
assert a.attachment_type == "configuration"
3742
assert a.content_type == "unknown/type"
3843
assert a.content == "kind: Pod\n metadata:\n name: private-reg"
44+
45+
def test_valid_image_attachment_jpeg(self) -> None:
46+
"""Test that a valid JPEG image attachment is accepted."""
47+
image_data = base64.b64encode(b"\xff\xd8\xff\xe0" + b"\x00" * 100).decode()
48+
a = Attachment(
49+
attachment_type="image",
50+
content_type="image/jpeg",
51+
content=image_data,
52+
)
53+
assert a.attachment_type == "image"
54+
assert a.content_type == "image/jpeg"
55+
56+
def test_valid_image_attachment_png(self) -> None:
57+
"""Test that a valid PNG image attachment is accepted."""
58+
image_data = base64.b64encode(b"\x89PNG" + b"\x00" * 100).decode()
59+
a = Attachment(
60+
attachment_type="image",
61+
content_type="image/png",
62+
content=image_data,
63+
)
64+
assert a.attachment_type == "image"
65+
assert a.content_type == "image/png"
66+
67+
def test_image_content_type_requires_image_attachment_type(self) -> None:
68+
"""Test that image content_type requires attachment_type='image'."""
69+
image_data = base64.b64encode(b"\xff\xd8\xff\xe0").decode()
70+
with pytest.raises(ValidationError, match="attachment_type must be 'image'"):
71+
Attachment(
72+
attachment_type="log",
73+
content_type="image/jpeg",
74+
content=image_data,
75+
)
76+
77+
def test_image_attachment_type_requires_image_content_type(self) -> None:
78+
"""Test that attachment_type='image' requires an image content_type."""
79+
with pytest.raises(
80+
ValidationError, match="content_type must be 'image/jpeg' or 'image/png'"
81+
):
82+
Attachment(
83+
attachment_type="image",
84+
content_type="text/plain",
85+
content="some text",
86+
)
87+
88+
def test_image_attachment_invalid_base64(self) -> None:
89+
"""Test that image attachment with invalid base64 is rejected."""
90+
with pytest.raises(ValidationError, match="Invalid base64"):
91+
Attachment(
92+
attachment_type="image",
93+
content_type="image/jpeg",
94+
content="not-valid-base64!!!",
95+
)
96+
97+
def test_image_attachment_exceeds_size_limit(self) -> None:
98+
"""Test that image attachment exceeding size limit is rejected."""
99+
large_data = base64.b64encode(b"\x00" * (100 * 1024 * 1024 + 1)).decode()
100+
with pytest.raises(ValidationError, match="exceeds maximum allowed size"):
101+
Attachment(
102+
attachment_type="image",
103+
content_type="image/png",
104+
content=large_data,
105+
)
106+
107+
def test_text_attachment_unchanged(self) -> None:
108+
"""Test that existing text attachments still work without changes."""
109+
a = Attachment(
110+
attachment_type="log",
111+
content_type="text/plain",
112+
content="some log output",
113+
)
114+
assert a.attachment_type == "log"
115+
assert a.content_type == "text/plain"

0 commit comments

Comments
 (0)