diff --git a/backend/src/baserow/core/generative_ai/exceptions.py b/backend/src/baserow/core/generative_ai/exceptions.py index 1d74b0dea3..a0050cbae4 100644 --- a/backend/src/baserow/core/generative_ai/exceptions.py +++ b/backend/src/baserow/core/generative_ai/exceptions.py @@ -15,3 +15,29 @@ def __init__(self, model_name, *args, **kwargs): class GenerativeAIPromptError(Exception): """Raised when an error occurs while prompting the model.""" + + +def get_user_friendly_error_message(exc: Exception) -> str: + """ + Extract a concise, user-facing message from a provider SDK exception. + + Provider SDKs (OpenAI, Anthropic, Mistral) include metadata like + status_code and model_name in their ``__str__`` output. Users only + care about the human-readable body/message part. + + :param exc: The exception raised by the provider SDK. + :return: A concise error message suitable for displaying to users. + """ + + # OpenAI / Anthropic APIStatusError exposes a `.body` dict or string + # with the actual error message from the provider. + body = getattr(exc, "body", None) + if isinstance(body, dict): + msg = body.get("message") or body.get("error", {}).get("message") + if msg: + return str(msg) + if isinstance(body, str) and body: + return body + + # Fallback: use the full string representation. + return str(exc) diff --git a/backend/src/baserow/core/generative_ai/generative_ai_model_types.py b/backend/src/baserow/core/generative_ai/generative_ai_model_types.py index 1169684ac8..6e9efb99ca 100644 --- a/backend/src/baserow/core/generative_ai/generative_ai_model_types.py +++ b/backend/src/baserow/core/generative_ai/generative_ai_model_types.py @@ -1,20 +1,190 @@ from __future__ import annotations -import os +from functools import cached_property from typing import TYPE_CHECKING, Any, Optional from django.conf import settings -from loguru import logger - from baserow.core.models import Workspace -from .registries import GenerativeAIModelType +from .registries import FileHandler, GenerativeAIModelType if TYPE_CHECKING: from baserow_premium.fields.ai_file import AIFile +_IMAGE_EXTENSIONS = {".gif", ".jpg", ".jpeg", ".png", ".webp"} +_TEXT_EXTENSIONS = {".csv", ".html", ".json", ".md", ".txt", ".tex"} + + +class EmbedOnlyFileHandler(FileHandler): + """For providers that only support embedding/inlining (no upload API). + Images and PDFs are embedded; small text files are inlined.""" + + _EMBEDDABLE_EXTENSIONS = _IMAGE_EXTENSIONS | {".pdf"} + _INLINEABLE_EXTENSIONS = _TEXT_EXTENSIONS + + +class OpenAIFileHandler(FileHandler): + """OpenAI file handler with Files API upload support.""" + + _EMBEDDABLE_EXTENSIONS = _IMAGE_EXTENSIONS + _INLINEABLE_EXTENSIONS = _TEXT_EXTENSIONS + _UPLOADABLE_EXTENSIONS = { + ".csv", + ".doc", + ".docx", + ".html", + ".json", + ".md", + ".pdf", + ".pptx", + ".txt", + ".tex", + ".xlsx", + ".xls", + } + # https://developers.openai.com/api/docs/guides/file-inputs + _MAX_EMBED_PAYLOAD_BYTES = 45 * 1024 * 1024 # 50 MB minus headroom + _MAX_EMBEDS_PER_REQUEST = 500 + + def __init__(self, model_type: OpenAIGenerativeAIModelType): + self._model_type = model_type + + def _get_max_upload_bytes(self) -> int: + """ + Return the maximum upload size in bytes, capped at 512 MB. + + :return: Max upload size in bytes. + """ + + return ( + min(512, settings.BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB) * 1024 * 1024 + ) + + def _can_upload_file(self, ext: str, size: int) -> bool: + return ( + ext in self._UPLOADABLE_EXTENSIONS and size <= self._get_max_upload_bytes() + ) + + def _get_upload_client( + self, + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> Any: + """ + Return a synchronous OpenAI client for file operations. + + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + :return: An ``openai.OpenAI`` client instance. + """ + + from openai import OpenAI + + mt = self._model_type + api_key = mt.get_api_key(workspace, settings_override) + organization = mt.get_organization(workspace, settings_override) + base_url = mt.get_base_url(workspace, settings_override) + return OpenAI(api_key=api_key, organization=organization, base_url=base_url) + + def _upload( + self, + ai_file: "AIFile", + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + from pydantic_ai import UploadedFile + + data = ai_file.read_content() + client = self._get_upload_client(workspace, settings_override) + uploaded = client.files.create(file=(ai_file.name, data), purpose="user_data") + ai_file.provider_file_id = uploaded.id + ai_file.content = UploadedFile( + file_id=uploaded.id, + provider_name="openai", + media_type=ai_file.mime_type, + identifier=ai_file.original_name, + ) + + def delete_file( + self, + ai_file: "AIFile", + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + client = self._get_upload_client(workspace, settings_override) + client.files.delete(ai_file.provider_file_id) + + +class AnthropicFileHandler(FileHandler): + """Anthropic file handler with beta Files API upload for PDFs.""" + + # https://platform.claude.com/docs/en/docs/build-with-claude/vision + # https://platform.claude.com/docs/en/docs/build-with-claude/pdf-support + # https://docs.anthropic.com/en/docs/build-with-claude/files + _EMBEDDABLE_EXTENSIONS = _IMAGE_EXTENSIONS + _INLINEABLE_EXTENSIONS = _TEXT_EXTENSIONS + _UPLOADABLE_EXTENSIONS = {".pdf"} + # 5 MB per image, 32 MB per request, 600 images/request, 600 pages + _MAX_EMBED_PAYLOAD_BYTES = 30 * 1024 * 1024 # 32 MB minus headroom + _MAX_EMBEDS_PER_REQUEST = 600 + + def __init__(self, model_type: AnthropicGenerativeAIModelType): + self._model_type = model_type + + def _get_sync_client( + self, + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> Any: + """ + Return a synchronous Anthropic client for file operations. + + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + :return: An ``anthropic.Anthropic`` client instance. + """ + + import anthropic + + api_key = self._model_type.get_api_key(workspace, settings_override) + return anthropic.Anthropic(api_key=api_key) + + def _upload( + self, + ai_file: "AIFile", + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + from pydantic_ai import UploadedFile + + data = ai_file.read_content() + client = self._get_sync_client(workspace, settings_override) + uploaded = client.beta.files.upload(file=(ai_file.name, data)) + ai_file.provider_file_id = uploaded.id + ai_file.content = UploadedFile( + file_id=uploaded.id, + provider_name="anthropic", + media_type=ai_file.mime_type, + identifier=ai_file.original_name, + ) + + def delete_file( + self, + ai_file: "AIFile", + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + client = self._get_sync_client(workspace, settings_override) + client.beta.files.delete(ai_file.provider_file_id) + + +# --------------------------------------------------------------------------- +# Model types +# --------------------------------------------------------------------------- + + class BaseOpenAIGenerativeAIModelType(GenerativeAIModelType): def get_api_key( self, @@ -53,18 +223,6 @@ def get_base_url( ) -> Optional[str]: return None - def is_enabled( - self, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> bool: - api_key = self.get_api_key(workspace, settings_override) - return bool(api_key) and bool( - self.get_enabled_models( - workspace=workspace, settings_override=settings_override - ) - ) - def get_ai_model( self, model_name: str, @@ -94,6 +252,10 @@ def get_settings_serializer(self) -> type: class OpenAIGenerativeAIModelType(BaseOpenAIGenerativeAIModelType): type = "openai" + @cached_property + def file_handler(self) -> OpenAIFileHandler: + return OpenAIFileHandler(self) + def get_settings_serializer(self) -> type: from baserow.api.generative_ai.serializers import OpenAISettingsSerializer @@ -109,172 +271,16 @@ def get_base_url( or settings.BASEROW_OPENAI_BASE_URL ) - supports_files = True - - _EMBEDDABLE_EXTENSIONS = {".gif", ".jpg", ".jpeg", ".png", ".webp"} - _UPLOADABLE_EXTENSIONS = { - ".csv", - ".doc", - ".docx", - ".html", - ".json", - ".md", - ".pdf", - ".pptx", - ".txt", - ".tex", - ".xlsx", - ".xls", - } - # https://developers.openai.com/api/docs/guides/file-inputs - _MAX_EMBED_PAYLOAD_BYTES = 45 * 1024 * 1024 # 50 MB minus headroom - _MAX_EMBEDS_PER_REQUEST = 500 - # Below this limit, uploadable files are sent inline. - _INLINE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 # 10 KB - - def _get_max_upload_bytes(self) -> int: - return ( - min(512, settings.BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB) * 1024 * 1024 - ) - - def _can_embed(self, file_size: int, embed_count: int, embed_payload: int) -> bool: - return ( - embed_count < self._MAX_EMBEDS_PER_REQUEST - and embed_payload + file_size <= self._MAX_EMBED_PAYLOAD_BYTES - ) - - @staticmethod - def _embed(ai_file: "AIFile", data: bytes) -> None: - from pydantic_ai import BinaryContent - - ai_file.content = BinaryContent( - data=data, - media_type=ai_file.mime_type, - identifier=ai_file.original_name, - ) - - @staticmethod - def _inline_text(ai_file: "AIFile", data: bytes) -> bool: - """Try to inline file content as TextContent. Returns False if the - content is not valid UTF-8.""" - - from pydantic_ai import TextContent - - try: - text = data.decode("utf-8") - except (UnicodeDecodeError, ValueError): - return False - ai_file.content = TextContent( - content=( - f"[Content of file '{ai_file.original_name}']\n{text}\n[End of file]" - ), - metadata={"source": ai_file.original_name}, - ) - return True - - def _upload( - self, - ai_file: "AIFile", - data: bytes, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> None: - from pydantic_ai import UploadedFile - - file_id = self._upload_file(ai_file.name, data, workspace, settings_override) - ai_file.provider_file_id = file_id - ai_file.content = UploadedFile( - file_id=file_id, - provider_name="openai", - media_type=ai_file.mime_type, - identifier=ai_file.original_name, - ) - - def prepare_files( - self, - files: list[AIFile], - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> list[AIFile]: - embed_payload = 0 - embed_count = 0 - max_upload = self._get_max_upload_bytes() - - for ai_file in files: - _, ext = os.path.splitext(ai_file.name) - ext = ext.lower() - - try: - if ext in self._EMBEDDABLE_EXTENSIONS: - if not self._can_embed(ai_file.size, embed_count, embed_payload): - continue - self._embed(ai_file, ai_file.read_content()) - embed_payload += ai_file.size - embed_count += 1 - - elif ext in self._UPLOADABLE_EXTENSIONS: - if ai_file.size > max_upload: - continue - data = ai_file.read_content() - - if ( - ai_file.size <= self._INLINE_UPLOAD_THRESHOLD_BYTES - and self._can_embed(ai_file.size, embed_count, embed_payload) - ): - if not self._inline_text(ai_file, data): - self._embed(ai_file, data) - embed_payload += ai_file.size - embed_count += 1 - else: - self._upload(ai_file, data, workspace, settings_override) - except Exception as exc: - logger.warning(f"Skipping file {ai_file.name}: {exc}") - continue - - return [f for f in files if f.content is not None] - - def _get_upload_client( - self, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> Any: - """Return a sync OpenAI client for file upload/delete operations.""" - - from openai import OpenAI - - api_key = self.get_api_key(workspace, settings_override) - organization = self.get_organization(workspace, settings_override) - base_url = self.get_base_url(workspace, settings_override) - return OpenAI(api_key=api_key, organization=organization, base_url=base_url) - - def _upload_file( - self, - file_name: str, - file_bytes: bytes, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> str: - client = self._get_upload_client(workspace, settings_override) - uploaded = client.files.create( - file=(file_name, file_bytes), purpose="user_data" - ) - return uploaded.id - - def delete_file( - self, - ai_file: AIFile, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> None: - """Delete an uploaded file from OpenAI.""" - - client = self._get_upload_client(workspace, settings_override) - client.files.delete(ai_file.provider_file_id) - class AnthropicGenerativeAIModelType(GenerativeAIModelType): type = "anthropic" + _FILES_API_BETA = "files-api-2025-04-14" + + @cached_property + def file_handler(self) -> AnthropicFileHandler: + return AnthropicFileHandler(self) + def get_api_key( self, workspace: Optional[Workspace] = None, @@ -295,18 +301,6 @@ def get_enabled_models( ) return workspace_models or settings.BASEROW_ANTHROPIC_MODELS - def is_enabled( - self, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> bool: - api_key = self.get_api_key(workspace, settings_override) - return bool(api_key) and bool( - self.get_enabled_models( - workspace=workspace, settings_override=settings_override - ) - ) - def get_ai_model( self, model_name: str, @@ -322,11 +316,12 @@ def get_ai_model( def _prepare_model_settings( self, temperature: Optional[float] = None ) -> dict[str, Any]: - settings: dict[str, Any] = {} + model_settings: dict[str, Any] = { + "extra_headers": {"anthropic-beta": self._FILES_API_BETA}, + } if temperature is not None: - # Anthropic only accepts temperature up to 1.0 - settings["temperature"] = min(temperature, 1) - return settings + model_settings["temperature"] = min(temperature, 1) + return model_settings def get_settings_serializer(self) -> type: from baserow.api.generative_ai.serializers import AnthropicSettingsSerializer @@ -335,8 +330,13 @@ def get_settings_serializer(self) -> type: class MistralGenerativeAIModelType(GenerativeAIModelType): + # https://docs.mistral.ai/capabilities/vision/ type = "mistral" + @cached_property + def file_handler(self) -> EmbedOnlyFileHandler: + return EmbedOnlyFileHandler() + def get_api_key( self, workspace: Optional[Workspace] = None, @@ -357,18 +357,6 @@ def get_enabled_models( ) return workspace_models or settings.BASEROW_MISTRAL_MODELS - def is_enabled( - self, - workspace: Optional[Workspace] = None, - settings_override: Optional[dict[str, Any]] = None, - ) -> bool: - api_key = self.get_api_key(workspace, settings_override) - return bool(api_key) and bool( - self.get_enabled_models( - workspace=workspace, settings_override=settings_override - ) - ) - def get_ai_model( self, model_name: str, @@ -384,11 +372,10 @@ def get_ai_model( def _prepare_model_settings( self, temperature: Optional[float] = None ) -> dict[str, Any]: - settings: dict[str, Any] = {} + model_settings: dict[str, Any] = {} if temperature is not None: - # Mistral only accepts temperature up to 1.0 - settings["temperature"] = min(temperature, 1) - return settings + model_settings["temperature"] = min(temperature, 1) + return model_settings def get_settings_serializer(self) -> type: from baserow.api.generative_ai.serializers import MistralSettingsSerializer @@ -399,6 +386,10 @@ def get_settings_serializer(self) -> type: class OllamaGenerativeAIModelType(BaseOpenAIGenerativeAIModelType): type = "ollama" + @cached_property + def file_handler(self) -> EmbedOnlyFileHandler: + return EmbedOnlyFileHandler() + def get_host( self, workspace: Optional[Workspace] = None, @@ -478,6 +469,10 @@ class OpenRouterGenerativeAIModelType(BaseOpenAIGenerativeAIModelType): type = "openrouter" + @cached_property + def file_handler(self) -> EmbedOnlyFileHandler: + return EmbedOnlyFileHandler() + def get_api_key( self, workspace: Optional[Workspace] = None, diff --git a/backend/src/baserow/core/generative_ai/registries.py b/backend/src/baserow/core/generative_ai/registries.py index 89ecd826d4..3e905fcaac 100644 --- a/backend/src/baserow/core/generative_ai/registries.py +++ b/backend/src/baserow/core/generative_ai/registries.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +from functools import cached_property from typing import TYPE_CHECKING, Any, Optional from loguru import logger @@ -8,7 +10,7 @@ from baserow.core.models import Workspace from baserow.core.registry import Instance, Registry -from .exceptions import GenerativeAITypeDoesNotExist +from .exceptions import GenerativeAITypeDoesNotExist, get_user_friendly_error_message if TYPE_CHECKING: from pydantic_ai import Agent @@ -16,8 +18,310 @@ from baserow_premium.fields.ai_file import AIFile +class FileHandler: + """Handles file processing for an AI provider. + + The cascade tries each strategy in order for every file: + inline (text) -> embed (binary) -> upload (API) -> skip. + + Subclasses configure behavior via extension sets and by overriding + ``_upload``, ``_can_upload_file``, and ``delete_file`` as needed. + """ + + _EMBEDDABLE_EXTENSIONS: set[str] = set() + _INLINEABLE_EXTENSIONS: set[str] = set() + _UPLOADABLE_EXTENSIONS: set[str] = set() + + _MAX_EMBED_PAYLOAD_BYTES = 45 * 1024 * 1024 # 50 MB minus headroom + _MAX_EMBEDS_PER_REQUEST = 500 + _INLINE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 # 10 KB + + def _has_embed_budget( + self, file_size: int, embed_count: int, embed_payload_size: int + ) -> bool: + """ + Check whether adding a file of the given size would stay within the + per-request embed limits. + + :param file_size: Size of the file in bytes. + :param embed_count: Number of files already embedded in this request. + :param embed_payload_size: Total bytes already embedded in this request. + :return: True if the file fits within both count and payload limits. + """ + + return ( + embed_count < self._MAX_EMBEDS_PER_REQUEST + and embed_payload_size + file_size <= self._MAX_EMBED_PAYLOAD_BYTES + ) + + def _can_inline_file( + self, ext: str, size: int, embed_count: int, embed_payload_size: int + ) -> bool: + """ + Check whether a file can be inlined as text content. + + :param ext: Lowercase file extension including the dot. + :param size: File size in bytes. + :param embed_count: Number of files already embedded in this request. + :param embed_payload_size: Total bytes already embedded in this request. + :return: True if the file extension is inlineable, the file is small + enough, and the embed budget has room. + """ + + return ( + ext in self._INLINEABLE_EXTENSIONS + and size <= self._INLINE_UPLOAD_THRESHOLD_BYTES + and self._has_embed_budget(size, embed_count, embed_payload_size) + ) + + def _can_embed_file( + self, ext: str, size: int, embed_count: int, embed_payload_size: int + ) -> bool: + """ + Check whether a file can be embedded as binary content. + + :param ext: Lowercase file extension including the dot. + :param size: File size in bytes. + :param embed_count: Number of files already embedded in this request. + :param embed_payload_size: Total bytes already embedded in this request. + :return: True if the file extension is embeddable and the embed budget + has room. + """ + + return ext in self._EMBEDDABLE_EXTENSIONS and self._has_embed_budget( + size, embed_count, embed_payload_size + ) + + def _can_upload_file(self, ext: str, size: int) -> bool: + """ + Check whether a file can be uploaded via the provider API. + + :param ext: Lowercase file extension including the dot. + :param size: File size in bytes. + :return: True if the file extension is uploadable. + """ + + return ext in self._UPLOADABLE_EXTENSIONS + + def _embed(self, ai_file: "AIFile") -> None: + """ + Embed a file as binary content by reading its bytes and setting + ``ai_file.content`` to a ``BinaryContent`` instance. + + :param ai_file: The file to embed. + """ + + from pydantic_ai import BinaryContent + + ai_file.content = BinaryContent( + data=ai_file.read_content(), + media_type=ai_file.mime_type, + identifier=ai_file.original_name, + ) + + def _inline_text(self, ai_file: "AIFile") -> bool: + """ + Try to inline file content as a ``TextContent`` instance. Sets + ``ai_file.content`` on success. + + :param ai_file: The file to inline. + :return: True if the file was valid UTF-8 and was inlined, False + otherwise. + """ + + from pydantic_ai import TextContent + + try: + text = ai_file.read_content().decode("utf-8") + except (UnicodeDecodeError, ValueError): + return False + ai_file.content = TextContent( + content=( + f"[Content of file '{ai_file.original_name}']\n{text}\n[End of file]" + ), + metadata={"source": ai_file.original_name}, + ) + return True + + def _upload( + self, + ai_file: "AIFile", + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + """ + Upload a file via the provider API. Must be overridden by subclasses + that declare ``_UPLOADABLE_EXTENSIONS``. Sets ``ai_file.content`` and + ``ai_file.provider_file_id`` on success. + + :param ai_file: The file to upload. + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + """ + + raise NotImplementedError( + f"{type(self).__name__} declares _UPLOADABLE_EXTENSIONS but does " + f"not implement _upload()" + ) + + def prepare_files( + self, + files: list["AIFile"], + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> list["AIFile"]: + """ + Process files into prompt content using the cascade: + inline -> embed -> upload -> skip. Only files that were + successfully processed (with ``content`` set) are returned. + + :param files: List of AIFile instances to process. + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + :return: The subset of files that were successfully processed. + """ + + embed_payload_size = 0 + embed_count = 0 + + for ai_file in files: + _, ext = os.path.splitext(ai_file.name) + ext = ext.lower() + + try: + if self._can_inline_file( + ext, ai_file.size, embed_count, embed_payload_size + ): + if self._inline_text(ai_file): + embed_payload_size += ai_file.size + embed_count += 1 + continue + + if self._can_embed_file( + ext, ai_file.size, embed_count, embed_payload_size + ): + self._embed(ai_file) + embed_payload_size += ai_file.size + embed_count += 1 + continue + + if self._can_upload_file(ext, ai_file.size): + self._upload(ai_file, workspace, settings_override) + except Exception as exc: + logger.warning(f"Skipping file {ai_file.name}: {exc}") + + return [f for f in files if f.content is not None] + + def delete_file( + self, + ai_file: "AIFile", + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + """ + Delete a single uploaded file from the provider. Must be overridden + by subclasses that upload files (i.e. that set ``provider_file_id`` + during ``_upload``). + + :param ai_file: The file to delete. + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + """ + + raise NotImplementedError( + f"{type(self).__name__} does not implement delete_file()" + ) + + def cleanup_files( + self, + files: list["AIFile"], + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + """ + Delete all provider-uploaded files. Only files with a + ``provider_file_id`` are processed. Safe to call with an empty list. + + :param files: List of AIFile instances returned by ``prepare_files``. + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + """ + + for ai_file in files: + if not ai_file.provider_file_id: + continue + try: + self.delete_file(ai_file, workspace, settings_override) + except Exception: + logger.warning( + f"Failed to delete provider file {ai_file.provider_file_id}." + ) + + class GenerativeAIModelType(Instance): - supports_files: bool = False + @cached_property + def file_handler(self) -> FileHandler | None: + """ + Return the file handler for this provider, or None if the provider + does not support files. Override in subclasses to return a concrete + ``FileHandler`` instance. + """ + + return None + + @property + def supports_files(self) -> bool: + """Return True if this provider supports file attachments.""" + + return self.file_handler is not None + + def prepare_files( + self, + files: list["AIFile"], + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> list["AIFile"]: + """ + Prepare files for prompting by processing them through the file handler, + if available. Returns the list of AIFile instances that were + successfully prepared (i.e. have their `content` attribute set). Should + be called before prompting, and the returned files should be passed to + the prompt via the `content` parameter for multi-modal input. + + :param files: The list of AIFile instances to prepare. + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + """ + + if self.file_handler is None: + raise NotImplementedError( + f"{type(self).__name__} does not support files. " + f"Check supports_files before calling prepare_files()." + ) + return self.file_handler.prepare_files(files, workspace, settings_override) + + def cleanup_files( + self, + files: list["AIFile"], + workspace: Optional[Workspace] = None, + settings_override: Optional[dict[str, Any]] = None, + ) -> None: + """ + Cleanup previously uploaded files via the file handler. Should be called + in a finally block after prompting, to ensure cleanup happens even if + prompting fails. + + :param files: The list of AIFile instances to clean up. + :param workspace: The workspace for settings resolution. + :param settings_override: Optional provider settings override. + """ + + if self.file_handler is None: + raise NotImplementedError( + f"{type(self).__name__} does not support files. " + f"Check supports_files before calling cleanup_files()." + ) + self.file_handler.cleanup_files(files, workspace, settings_override) def get_workspace_setting( self, @@ -45,75 +349,55 @@ def get_workspace_setting( type_settings = settings.get(self.type, {}) return type_settings.get(key, None) - def is_enabled(self, workspace: Optional[Workspace] = None) -> bool: - return False - - def get_enabled_models(self, workspace: Optional[Workspace] = None) -> list[str]: - return [] - - def prepare_files( + def get_api_key( self, - files: list[AIFile], workspace: Optional[Workspace] = None, settings_override: Optional[dict[str, Any]] = None, - ) -> list[AIFile]: - """Process files into prompt content. Each provider implements its - own logic for deciding what to embed, what to upload, and which files - to skip. - - Providers set ``content`` and optionally ``provider_file_id`` on each - accepted file. Only processed files (those with ``content`` set) are - returned; skipped files are filtered out. + ) -> Optional[str]: + """ + Return the API key for this provider, or None if not configured. - :param files: List of AIFile instances with metadata and lazy - ``read_content()`` method. :param workspace: The workspace for settings resolution. :param settings_override: Optional provider settings override. - :return: The processed files with content/provider_file_id set. + :return: The API key string, or None. """ - return [] + return None - def delete_file( + def is_enabled( self, - ai_file: AIFile, workspace: Optional[Workspace] = None, settings_override: Optional[dict[str, Any]] = None, - ) -> None: + ) -> bool: """ - Delete a single uploaded file from the provider. Override in - subclasses that upload files in ``prepare_files``. + Return True if this provider has both an API key and at least one + enabled model. Ollama overrides this to check the host instead. - :param ai_file: The AIFile instance representing the file to delete. :param workspace: The workspace for settings resolution. :param settings_override: Optional provider settings override. + :return: True if the provider is enabled. """ - def cleanup_files( + return bool(self.get_api_key(workspace, settings_override)) and bool( + self.get_enabled_models( + workspace=workspace, settings_override=settings_override + ) + ) + + def get_enabled_models( self, - files: list[AIFile], workspace: Optional[Workspace] = None, settings_override: Optional[dict[str, Any]] = None, - ) -> None: + ) -> list[str]: """ - Clean up provider-uploaded files. Only files with a - ``provider_file_id`` are processed. Safe to call with an empty list. + Return the list of enabled model names for this provider. - :param files: List of AIFile instances returned by ``prepare_files``. :param workspace: The workspace for settings resolution. :param settings_override: Optional provider settings override. + :return: List of model name strings, empty if none configured. """ - for ai_file in files: - if not ai_file.provider_file_id: - continue - try: - self.delete_file(ai_file, workspace, settings_override) - except Exception: - logger.warning( - f"Failed to delete file {ai_file.provider_file_id} from " - f"provider {self.type}." - ) + return [] def get_ai_model( self, @@ -298,14 +582,28 @@ def prompt( except GenerativeAIPromptError: raise except Exception as e: - raise GenerativeAIPromptError(str(e)) from e + raise GenerativeAIPromptError(get_user_friendly_error_message(e)) from e def get_settings_serializer(self) -> type: + """ + Return the DRF serializer class for this provider's workspace-level + settings (API key, models list, etc.). + + :return: A serializer class. + """ + raise NotImplementedError( "The get_settings_serializer function must be implemented." ) def get_serializer(self) -> type: + """ + Return the DRF serializer class for the provider's public + representation (type name, enabled models, etc.). + + :return: A serializer class. + """ + from baserow.api.generative_ai.serializers import GenerativeAIModelsSerializer return GenerativeAIModelsSerializer diff --git a/backend/src/baserow/test_utils/fixtures/generative_ai.py b/backend/src/baserow/test_utils/fixtures/generative_ai.py index 23d3d933e5..70dd80ae5f 100644 --- a/backend/src/baserow/test_utils/fixtures/generative_ai.py +++ b/backend/src/baserow/test_utils/fixtures/generative_ai.py @@ -95,6 +95,9 @@ def prepare_files(self, files, workspace=None, settings_override=None): break # first file only return [f for f in files if f.content is not None] + def cleanup_files(self, files, workspace=None, settings_override=None): + pass + def delete_file(self, ai_file, workspace=None, settings_override=None): pass diff --git a/backend/tests/baserow/core/generative_ai/test_generative_ai_model_types.py b/backend/tests/baserow/core/generative_ai/test_generative_ai_model_types.py index 2c02e351cf..7360bdee5e 100644 --- a/backend/tests/baserow/core/generative_ai/test_generative_ai_model_types.py +++ b/backend/tests/baserow/core/generative_ai/test_generative_ai_model_types.py @@ -3,7 +3,11 @@ from pydantic_ai import BinaryContent, TextContent, UploadedFile from baserow.core.generative_ai.generative_ai_model_types import ( + AnthropicGenerativeAIModelType, + MistralGenerativeAIModelType, + OllamaGenerativeAIModelType, OpenAIGenerativeAIModelType, + OpenRouterGenerativeAIModelType, ) from baserow_premium.fields.ai_file import AIFile @@ -27,31 +31,32 @@ def test_openai_supports_files(): def test_openai_embeddable_and_uploadable_extensions(): - ai_model_type = OpenAIGenerativeAIModelType() + handler = OpenAIGenerativeAIModelType().file_handler # Documents → uploadable - assert ".txt" in ai_model_type._UPLOADABLE_EXTENSIONS - assert ".pdf" in ai_model_type._UPLOADABLE_EXTENSIONS - assert ".csv" in ai_model_type._UPLOADABLE_EXTENSIONS + assert ".txt" in handler._UPLOADABLE_EXTENSIONS + assert ".pdf" in handler._UPLOADABLE_EXTENSIONS + assert ".csv" in handler._UPLOADABLE_EXTENSIONS # Images → embeddable - assert ".png" in ai_model_type._EMBEDDABLE_EXTENSIONS - assert ".jpg" in ai_model_type._EMBEDDABLE_EXTENSIONS + assert ".png" in handler._EMBEDDABLE_EXTENSIONS + assert ".jpg" in handler._EMBEDDABLE_EXTENSIONS # Unsupported assert ".mp4" not in ( - ai_model_type._EMBEDDABLE_EXTENSIONS | ai_model_type._UPLOADABLE_EXTENSIONS + handler._EMBEDDABLE_EXTENSIONS | handler._UPLOADABLE_EXTENSIONS ) def test_openai_max_upload_size(settings): ai_model_type = OpenAIGenerativeAIModelType() + handler = ai_model_type.file_handler settings.BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB = 1000 - assert ai_model_type._get_max_upload_bytes() == 512 * 1024 * 1024 + assert handler._get_max_upload_bytes() == 512 * 1024 * 1024 settings.BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB = 100 - assert ai_model_type._get_max_upload_bytes() == 100 * 1024 * 1024 + assert handler._get_max_upload_bytes() == 100 * 1024 * 1024 def test_prepare_files_small_text_file_is_inlined(): @@ -70,8 +75,8 @@ def test_prepare_files_small_text_file_is_inlined(): assert result[0].provider_file_id is None -def test_prepare_files_small_binary_uploadable_is_embedded(): - """A small non-UTF-8 uploadable file falls back to BinaryContent.""" +def test_prepare_files_small_binary_uploadable_is_uploaded(): + """A small non-UTF-8 inlineable+uploadable file falls through to upload.""" ai_model_type = OpenAIGenerativeAIModelType() data = b"\x80\x81\x82" @@ -79,22 +84,41 @@ def test_prepare_files_small_binary_uploadable_is_embedded(): "data.csv", size=len(data), mime_type="text/csv", content_bytes=data ) - result = ai_model_type.prepare_files([ai_file]) + def fake_upload(f, workspace=None, settings_override=None): + f.provider_file_id = "file-bin" + f.content = UploadedFile( + file_id="file-bin", + provider_name="openai", + media_type=f.mime_type, + identifier=f.original_name, + ) + + with patch.object(ai_model_type.file_handler, "_upload", side_effect=fake_upload): + result = ai_model_type.prepare_files([ai_file]) assert len(result) == 1 - assert isinstance(result[0].content, BinaryContent) - assert result[0].provider_file_id is None + assert isinstance(result[0].content, UploadedFile) + assert result[0].provider_file_id == "file-bin" def test_prepare_files_large_uploadable_is_uploaded(): """A .txt file over the inline threshold should be uploaded via the Files API.""" ai_model_type = OpenAIGenerativeAIModelType() - size = ai_model_type._INLINE_UPLOAD_THRESHOLD_BYTES + 1 + size = ai_model_type.file_handler._INLINE_UPLOAD_THRESHOLD_BYTES + 1 data = b"x" * size ai_file = _make_ai_file("big.txt", size=size, content_bytes=data) - with patch.object(ai_model_type, "_upload_file", return_value="file-123"): + def fake_upload(f, workspace=None, settings_override=None): + f.provider_file_id = "file-123" + f.content = UploadedFile( + file_id="file-123", + provider_name="openai", + media_type=f.mime_type, + identifier=f.original_name, + ) + + with patch.object(ai_model_type.file_handler, "_upload", side_effect=fake_upload): result = ai_model_type.prepare_files([ai_file]) assert len(result) == 1 @@ -106,17 +130,26 @@ def test_prepare_files_small_uploadable_respects_embed_limits(): """When embed payload would exceed the limit, small files fall back to upload.""" ai_model_type = OpenAIGenerativeAIModelType() + handler = ai_model_type.file_handler data = b"small" ai_file = _make_ai_file("a.txt", size=len(data), content_bytes=data) - # Pretend we already used up the embed budget by setting the limit to 0. - original = ai_model_type._MAX_EMBED_PAYLOAD_BYTES - ai_model_type._MAX_EMBED_PAYLOAD_BYTES = 0 + def fake_upload(f, workspace=None, settings_override=None): + f.provider_file_id = "file-456" + f.content = UploadedFile( + file_id="file-456", + provider_name="openai", + media_type=f.mime_type, + identifier=f.original_name, + ) + + original = handler._MAX_EMBED_PAYLOAD_BYTES + handler._MAX_EMBED_PAYLOAD_BYTES = 0 try: - with patch.object(ai_model_type, "_upload_file", return_value="file-456"): + with patch.object(handler, "_upload", side_effect=fake_upload): result = ai_model_type.prepare_files([ai_file]) finally: - ai_model_type._MAX_EMBED_PAYLOAD_BYTES = original + handler._MAX_EMBED_PAYLOAD_BYTES = original assert len(result) == 1 assert isinstance(result[0].content, UploadedFile) @@ -159,7 +192,7 @@ def test_prepare_files_oversized_uploadable_is_skipped(settings): ai_model_type = OpenAIGenerativeAIModelType() settings.BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB = 1 - limit = ai_model_type._get_max_upload_bytes() + limit = ai_model_type.file_handler._get_max_upload_bytes() data = b"x" * (limit + 1) ai_file = _make_ai_file("huge.txt", size=len(data), content_bytes=data) @@ -167,3 +200,213 @@ def test_prepare_files_oversized_uploadable_is_skipped(settings): assert len(result) == 0 assert ai_file.content is None + + +def test_anthropic_supports_files(): + assert AnthropicGenerativeAIModelType().supports_files is True + + +def test_anthropic_prepare_files_image_is_embedded(): + ai_model_type = AnthropicGenerativeAIModelType() + data = b"\x89PNG\r\n\x1a\n" + ai_file = _make_ai_file( + "photo.png", size=len(data), mime_type="image/png", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, BinaryContent) + + +def test_anthropic_prepare_files_pdf_is_uploaded(): + """PDFs are always uploaded via the Files API for Anthropic.""" + + ai_model_type = AnthropicGenerativeAIModelType() + data = b"%PDF-1.4 fake" + ai_file = _make_ai_file( + "doc.pdf", size=len(data), mime_type="application/pdf", content_bytes=data + ) + + def fake_upload(f, workspace=None, settings_override=None): + f.content = "uploaded" + + with patch.object(ai_model_type.file_handler, "_upload", side_effect=fake_upload): + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + + +def test_anthropic_prepare_files_small_text_is_inlined(): + ai_model_type = AnthropicGenerativeAIModelType() + data = b"hello world" + ai_file = _make_ai_file("notes.txt", size=len(data), content_bytes=data) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, TextContent) + assert "hello world" in result[0].content.content + + +def test_anthropic_prepare_files_unsupported_is_skipped(): + ai_model_type = AnthropicGenerativeAIModelType() + data = b"data" + ai_file = _make_ai_file( + "sheet.xlsx", + size=len(data), + mime_type="application/vnd.ms-excel", + content_bytes=data, + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 0 + + +def test_anthropic_prepare_files_large_text_is_skipped(): + """Text files over the inline threshold are skipped (no upload API).""" + + ai_model_type = AnthropicGenerativeAIModelType() + size = ai_model_type.file_handler._INLINE_UPLOAD_THRESHOLD_BYTES + 1 + data = b"x" * size + ai_file = _make_ai_file("big.txt", size=size, content_bytes=data) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 0 + + +def test_mistral_supports_files(): + assert MistralGenerativeAIModelType().supports_files is True + + +def test_mistral_prepare_files_image_is_embedded(): + ai_model_type = MistralGenerativeAIModelType() + data = b"\xff\xd8\xff\xe0" + ai_file = _make_ai_file( + "photo.jpg", size=len(data), mime_type="image/jpeg", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, BinaryContent) + + +def test_mistral_prepare_files_small_text_is_inlined(): + ai_model_type = MistralGenerativeAIModelType() + data = b"some csv data" + ai_file = _make_ai_file( + "data.csv", size=len(data), mime_type="text/csv", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, TextContent) + + +# --- Ollama (embed-only) --- + + +def test_ollama_supports_files(): + assert OllamaGenerativeAIModelType().supports_files is True + + +def test_ollama_prepare_files_image_is_embedded(): + ai_model_type = OllamaGenerativeAIModelType() + data = b"\x89PNG\r\n\x1a\n" + ai_file = _make_ai_file( + "photo.png", size=len(data), mime_type="image/png", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, BinaryContent) + + +def test_ollama_prepare_files_pdf_is_embedded(): + ai_model_type = OllamaGenerativeAIModelType() + data = b"%PDF-1.4 fake" + ai_file = _make_ai_file( + "doc.pdf", size=len(data), mime_type="application/pdf", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, BinaryContent) + + +def test_ollama_prepare_files_small_text_is_inlined(): + ai_model_type = OllamaGenerativeAIModelType() + data = b"hello from ollama" + ai_file = _make_ai_file("notes.txt", size=len(data), content_bytes=data) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, TextContent) + assert "hello from ollama" in result[0].content.content + + +def test_ollama_prepare_files_unsupported_is_skipped(): + ai_model_type = OllamaGenerativeAIModelType() + data = b"data" + ai_file = _make_ai_file( + "sheet.xlsx", + size=len(data), + mime_type="application/vnd.ms-excel", + content_bytes=data, + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 0 + + +# --- OpenRouter (embed-only) --- + + +def test_openrouter_supports_files(): + assert OpenRouterGenerativeAIModelType().supports_files is True + + +def test_openrouter_prepare_files_image_is_embedded(): + ai_model_type = OpenRouterGenerativeAIModelType() + data = b"\xff\xd8\xff\xe0" + ai_file = _make_ai_file( + "photo.jpg", size=len(data), mime_type="image/jpeg", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, BinaryContent) + + +def test_openrouter_prepare_files_small_text_is_inlined(): + ai_model_type = OpenRouterGenerativeAIModelType() + data = b"some data" + ai_file = _make_ai_file( + "data.csv", size=len(data), mime_type="text/csv", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 1 + assert isinstance(result[0].content, TextContent) + + +def test_openrouter_prepare_files_unsupported_is_skipped(): + ai_model_type = OpenRouterGenerativeAIModelType() + data = b"data" + ai_file = _make_ai_file( + "video.mp4", size=len(data), mime_type="video/mp4", content_bytes=data + ) + + result = ai_model_type.prepare_files([ai_file]) + + assert len(result) == 0 diff --git a/changelog/entries/unreleased/feature/add_file_support_anthropic_mistral.json b/changelog/entries/unreleased/feature/add_file_support_anthropic_mistral.json new file mode 100644 index 0000000000..a9fbdaf7c1 --- /dev/null +++ b/changelog/entries/unreleased/feature/add_file_support_anthropic_mistral.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Add file attachment support for Anthropic, Mistral and other AI field providers", + "issue_origin": "github", + "issue_number": 5154, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-09" +} diff --git a/premium/backend/src/baserow_premium/fields/field_types.py b/premium/backend/src/baserow_premium/fields/field_types.py index 1a3ea50dae..4bcf5054d7 100644 --- a/premium/backend/src/baserow_premium/fields/field_types.py +++ b/premium/backend/src/baserow_premium/fields/field_types.py @@ -345,7 +345,11 @@ def _validate_field_kwargs( def get_field_dependencies( self, field_instance: AIField, field_cache: "FieldCache" ) -> FieldDependencies: - field_ids = extract_field_id_dependencies(field_instance.ai_prompt["formula"]) + field_ids = set( + extract_field_id_dependencies(field_instance.ai_prompt["formula"]) + ) + if field_instance.ai_file_field_id is not None: + field_ids.add(field_instance.ai_file_field_id) existing_field_ids = set( Field.objects.filter(id__in=field_ids).values_list("id", flat=True) ) diff --git a/premium/backend/src/baserow_premium/fields/handler.py b/premium/backend/src/baserow_premium/fields/handler.py index 9d72f04990..ea2ed32101 100644 --- a/premium/backend/src/baserow_premium/fields/handler.py +++ b/premium/backend/src/baserow_premium/fields/handler.py @@ -143,8 +143,8 @@ def generate_value_with_ai( # 3. Prepare files, call AI, cleanup ai_files: list[AIFile] = [] use_files = ( - ai_field.ai_file_field_id is not None - and generative_ai_model_type.supports_files + generative_ai_model_type.supports_files + and ai_field.ai_file_field_id is not None ) try: if use_files: diff --git a/premium/backend/src/baserow_premium/fields/job_types.py b/premium/backend/src/baserow_premium/fields/job_types.py index d9f0ce19a6..7c9b75ad68 100644 --- a/premium/backend/src/baserow_premium/fields/job_types.py +++ b/premium/backend/src/baserow_premium/fields/job_types.py @@ -26,6 +26,7 @@ from baserow.core.generative_ai.exceptions import ( GenerativeAIPromptError, ModelDoesNotBelongToType, + get_user_friendly_error_message, ) from baserow.core.handler import CoreHandler from baserow.core.job_types import _empty_transaction_context @@ -306,7 +307,7 @@ def on_progress(value_update: AIValueUpdate): rows=[row], field=ai_field, table=table, - error_message=str(value_update.result), + error_message=get_user_friendly_error_message(value_update.result), ) return @@ -409,7 +410,7 @@ def prepare(self): rows=[], field=self.ai_field, table=self.ai_field.table, - error_message=str(exc), + error_message=get_user_friendly_error_message(exc), ) raise exc @@ -464,9 +465,7 @@ def raise_if_error(self): """ if self.error_msg: - raise GenerativeAIPromptError( - f"AI model responded with errors: {self.error_msg}" - ) + raise GenerativeAIPromptError(self.error_msg) def process(self, rows: QuerySet[GeneratedTableModel]): """ diff --git a/web-frontend/modules/core/generativeAIModelTypes.js b/web-frontend/modules/core/generativeAIModelTypes.js index f0b4f34ce1..4e1c788738 100644 --- a/web-frontend/modules/core/generativeAIModelTypes.js +++ b/web-frontend/modules/core/generativeAIModelTypes.js @@ -114,6 +114,10 @@ export class AnthropicModelType extends GenerativeAIModelType { return 20 } + canPromptWithFiles() { + return true + } + getMaxTemperature() { return 1 } @@ -148,6 +152,10 @@ export class MistralModelType extends GenerativeAIModelType { return 30 } + canPromptWithFiles() { + return true + } + getMaxTemperature() { return 1 } @@ -178,6 +186,10 @@ export class OllamaModelType extends GenerativeAIModelType { ] } + canPromptWithFiles() { + return true + } + getOrder() { return 40 } @@ -219,6 +231,10 @@ export class OpenRouterModelType extends GenerativeAIModelType { ] } + canPromptWithFiles() { + return true + } + getOrder() { return 50 } diff --git a/web-frontend/modules/database/store/view/grid.js b/web-frontend/modules/database/store/view/grid.js index 333cc43815..fd3cc289e3 100644 --- a/web-frontend/modules/database/store/view/grid.js +++ b/web-frontend/modules/database/store/view/grid.js @@ -3545,7 +3545,7 @@ export const actions = { setPendingFieldOperations({ commit }, { fieldId, rowIds, value = true }) { commit('SET_PENDING_FIELD_OPERATIONS', { fieldId, rowIds, value }) }, - AIValuesGenerationError({ commit, dispatch }, { fieldId, rowIds }) { + AIValuesGenerationError({ commit, dispatch }, { fieldId, rowIds, error }) { const { $registry, $client, $i18n, $config } = this // If rowIds is empty, clear ALL pending operations for this field. if (rowIds.length === 0) { @@ -3557,7 +3557,9 @@ export const actions = { 'toast/error', { title: $i18n.t('gridView.AIValuesGenerationErrorTitle'), - message: $i18n.t('gridView.AIValuesGenerationErrorMessage'), + message: + (error && error.charAt(0).toUpperCase() + error.slice(1)) || + $i18n.t('gridView.AIValuesGenerationErrorMessage'), }, { root: true } )