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
2 changes: 1 addition & 1 deletion packages/ragbits-chat/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = ["fastapi[standard]>=0.115.0,<1.0.0", "uvicorn>=0.31.0,<1.0.0", "httpx>=0.28.1,<1.0.0", "bcrypt>=4.2.0", "python-jose[cryptography]>=3.5.0", "ragbits-agents==1.7.0.dev202604280307", "ragbits-core==1.7.0.dev202604280307"]
dependencies = ["fastapi[standard]>=0.115.0,<1.0.0", "uvicorn>=0.31.0,<1.0.0", "httpx>=0.28.1,<1.0.0", "bcrypt>=4.2.0", "python-jose[cryptography]>=3.5.0", "filetype>=1.2.0,<2.0.0", "ragbits-agents==1.7.0.dev202604280307", "ragbits-core==1.7.0.dev202604280307"]

[project.urls]
"Homepage" = "https://github.com/deepsense-ai/ragbits"
Expand Down
93 changes: 91 additions & 2 deletions packages/ragbits-chat/src/ragbits/chat/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pathlib import Path
from typing import Any, cast

import filetype
import uvicorn
from fastapi import FastAPI, HTTPException, Request, UploadFile, status
from fastapi.exceptions import RequestValidationError
Expand Down Expand Up @@ -309,6 +310,7 @@ async def config() -> JSONResponse:
oauth2_providers=oauth2_providers,
),
supports_upload=(self.chat_interface.supports_upload or self.chat_interface.upload_handler is not None),
attachments=self.chat_interface.attachments,
)

return JSONResponse(content=config_response.model_dump())
Expand Down Expand Up @@ -385,21 +387,108 @@ def _prepare_chat_context(
async def _build_attachments(self, files: list[UploadFile] | None) -> list[Attachment]:
"""Read multipart upload parts into ragbits-core Attachment objects.

Validates the request against `ChatInterface.attachments` first (count,
size, MIME allowlist; uploads must be enabled). All validation failures
are collected and reported together via RequestValidationError (422).

If the chat interface defines an ``upload_handler``, it is invoked per
file: a returned Attachment is appended, ``None`` drops the file. Without
a handler, every file becomes a default Attachment carrying its bytes.
"""
if not files:
return []

handler = self.chat_interface.upload_handler
uploads_enabled = self.chat_interface.supports_upload or handler is not None

if not uploads_enabled:
raise RequestValidationError(
errors=[
{
"loc": ("body", "files"),
"msg": "File uploads are disabled. Use ChatInterface.supports_upload",
"type": "value_error.uploads_disabled",
}
],
body=None,
)

config = self.chat_interface.attachments
max_bytes = config.max_size_mb * 1024 * 1024
allowed_mimes = {m.lower() for m in config.allowed_mime_types}
errors: list[dict[str, Any]] = []

if len(files) > config.max_attachments_per_message:
errors.append(
{
"loc": ("body", "files"),
"msg": (
f"Too many attachments: {len(files)} exceeds limit of " f"{config.max_attachments_per_message}"
),
"type": "value_error.too_many_attachments",
}
)

# Read each file once and sniff the actual MIME from the content
validated: list[tuple[UploadFile, bytes]] = []
for idx, f in enumerate(files):
loc = ("body", "files", idx)
declared_mime = (f.content_type or "").lower()
data = await f.read(max_bytes + 1)

if len(data) > max_bytes:
errors.append(
{
"loc": loc,
"msg": f"Attachment '{f.filename}' too large: exceeds {config.max_size_mb} MB limit",
"type": "value_error.file_too_large",
}
)
continue

if declared_mime not in allowed_mimes:
errors.append(
{
"loc": loc,
"msg": (
f"Attachment '{f.filename}' has unsupported MIME type " f"'{declared_mime or 'unknown'}'"
),
"type": "value_error.unsupported_mime",
}
)
continue

sniffed = filetype.guess(data)
sniffed_mime = sniffed.mime.lower() if sniffed else None
if sniffed_mime and sniffed_mime != declared_mime:
errors.append(
{
"loc": loc,
"msg": (
f"Attachment '{f.filename}' content (detected as '{sniffed_mime}') "
f"does not match declared MIME type '{declared_mime}'"
),
"type": "value_error.mime_mismatch",
}
)
continue

validated.append((f, data))

if errors:
raise RequestValidationError(errors=errors, body=None)

attachments: list[Attachment] = []
for f in files or []:
for f, data in validated:
if handler is not None:
await f.seek(0)
result = await handler(f)
if result is not None:
attachments.append(result)
else:
attachments.append(
Attachment(
data=await f.read(),
data=data,
mime_type=f.content_type,
filename=f.filename,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ..persistence import HistoryPersistenceStrategy
from .forms import FeedbackConfig, UserSettings
from .types import (
AttachmentsConfig,
ChatContext,
ChatResponseUnion,
ClearMessageContent,
Expand Down Expand Up @@ -237,6 +238,7 @@ async def upload_handler(self, file: UploadFile) -> Attachment | None:
history_persistence: HistoryPersistenceStrategy | None = None
summary_generator: SummaryGenerator = HeuristicSummaryGenerator()
supports_upload: bool = False
attachments: AttachmentsConfig = AttachmentsConfig()
upload_handler: Callable[[UploadFile], Awaitable[Attachment | None]] | None = None

def __init_subclass__(cls, **kwargs: dict) -> None:
Expand Down
19 changes: 19 additions & 0 deletions packages/ragbits-chat/src/ragbits/chat/interface/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,13 +902,32 @@ class AuthenticationConfig(BaseModel):
)


class AttachmentsConfig(BaseModel):
"""Server-side limits for file attachments on /api/chat."""

max_size_mb: int = Field(default=10, ge=1, description="Maximum size of a single attachment in MB")
max_attachments_per_message: int = Field(
default=5,
ge=1,
description="Maximum number of attachments per chat message",
)
allowed_mime_types: list[str] = Field(
default_factory=lambda: ["image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf"],
description="MIME types accepted by the server",
)


class ConfigResponse(BaseModel):
"""Configuration response from the API."""

feedback: FeedbackConfig = Field(..., description="Feedback configuration")
customization: UICustomization | None = Field(default=None, description="UI customization")
user_settings: UserSettings = Field(default_factory=UserSettings, description="User settings")
supports_upload: bool = Field(default=False, description="Flag indicating whether API supports file upload")
attachments: AttachmentsConfig = Field(
default_factory=AttachmentsConfig,
description="Server-side attachment limits.",
)
debug_mode: bool = Field(default=False, description="Debug mode flag")
conversation_history: bool = Field(default=False, description="Flag to enable conversation history")
show_usage: bool = Field(default=False, description="Flag to enable usage statistics")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def get_models(self) -> dict[str, type[BaseModel | Enum]]:
)
from ragbits.chat.interface.forms import UserSettings
from ragbits.chat.interface.types import (
AttachmentsConfig,
AuthenticationConfig,
ChatContext,
ChatMessageRequest,
Expand Down Expand Up @@ -84,6 +85,7 @@ def get_models(self) -> dict[str, type[BaseModel | Enum]]:
PageMetaCustomization,
UICustomization,
)
from ragbits.core.prompt import Attachment

self._models_cache = {
# Enums
Expand All @@ -92,6 +94,7 @@ def get_models(self) -> dict[str, type[BaseModel | Enum]]:
"MessageRole": MessageRole,
"TaskStatus": TaskStatus,
# Core data models
"Attachment": Attachment,
"ChatContext": ChatContext,
"ChunkedContent": ChunkedContent,
"LiveUpdate": LiveUpdate,
Expand Down Expand Up @@ -120,6 +123,7 @@ def get_models(self) -> dict[str, type[BaseModel | Enum]]:
"PageMetaCustomization": PageMetaCustomization,
"UserSettings": UserSettings,
"FeedbackConfig": FeedbackConfig,
"AttachmentsConfig": AttachmentsConfig,
# API response models
"ConfigResponse": ConfigResponse,
"FeedbackResponse": FeedbackResponse,
Expand Down Expand Up @@ -160,6 +164,7 @@ def get_categories(self) -> dict[str, list[str]]:
if self._models_cache
else [],
"core_data": [
"Attachment",
"ChatContext",
"ChunkedContent",
"LiveUpdate",
Expand Down Expand Up @@ -192,6 +197,7 @@ def get_categories(self) -> dict[str, list[str]]:
"FeedbackConfig",
"AuthenticationConfig",
"OAuth2ProviderConfig",
"AttachmentsConfig",
],
"responses": [
"FeedbackResponse",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Loading