From c67eff999c36dfcec4a98f075aafbec53289702c Mon Sep 17 00:00:00 2001 From: mwqgithub Date: Thu, 29 Jan 2026 15:21:35 +0800 Subject: [PATCH 1/3] multimodal --- .../common/configuration/__init__.py | 9 ++ src/memmachine/main/memmachine.py | 10 +++ .../server/api_v2/image_summarization.py | 85 ++++++++++++++++++ src/memmachine/server/api_v2/router.py | 88 ++++++++++++++++++- tests/memmachine/server/api_v2/test_router.py | 37 ++++++++ 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/memmachine/server/api_v2/image_summarization.py diff --git a/src/memmachine/common/configuration/__init__.py b/src/memmachine/common/configuration/__init__.py index 58896ea8c..fe151d4f8 100644 --- a/src/memmachine/common/configuration/__init__.py +++ b/src/memmachine/common/configuration/__init__.py @@ -250,6 +250,14 @@ class Configuration(BaseModel): episode_store: EpisodeStoreConf server: ServerConf = ServerConf() + image_summarization_model: str | None = Field( + default=None, + description=( + "Optional language model ID (from resources.language_models) to use " + "for summarizing uploaded images when adding memories via multipart." + ), + ) + def check_reranker(self, reranker_name: str) -> None: long_term_memory = self.episodic_memory.long_term_memory if not reranker_name or not long_term_memory: @@ -288,6 +296,7 @@ def to_yaml(self) -> str: "resources": self.resources.to_yaml_dict(), "episode_store": self.episode_store.to_yaml_dict(), "server": self.server.to_yaml_dict(), + "image_summarization_model": self.image_summarization_model, } return yaml.safe_dump(data, sort_keys=True) diff --git a/src/memmachine/main/memmachine.py b/src/memmachine/main/memmachine.py index 050b14039..4a8a41829 100644 --- a/src/memmachine/main/memmachine.py +++ b/src/memmachine/main/memmachine.py @@ -76,6 +76,16 @@ def __init__( self._initialize_default_episodic_configuration() self._started = False + @property + def config(self) -> Configuration: + """Return the active MemMachine configuration.""" + return self._conf + + @property + def resources(self) -> ResourceManagerImpl: + """Return the resource manager used by this MemMachine instance.""" + return self._resources + def _initialize_default_episodic_configuration(self) -> None: # initialize the default value for episodic memory configuration # Can not put the logic into the data type diff --git a/src/memmachine/server/api_v2/image_summarization.py b/src/memmachine/server/api_v2/image_summarization.py new file mode 100644 index 000000000..f3f69a342 --- /dev/null +++ b/src/memmachine/server/api_v2/image_summarization.py @@ -0,0 +1,85 @@ +"""Image summarization helpers for the API v2 server.""" + +from __future__ import annotations + +import base64 +import logging + +import openai + +from memmachine.common.configuration import Configuration +from memmachine.common.errors import ConfigurationError +from memmachine.common.resource_manager.resource_manager import ResourceManagerImpl + +logger = logging.getLogger(__name__) + + +_IMAGE_SUMMARY_SYSTEM_PROMPT = ( + "You are a helpful assistant that summarizes images. " + "Respond in concise English." +) + +_IMAGE_SUMMARY_USER_PROMPT = ( + "Summarize the key information in this image.\n" + "Requirements: concise and objective; do not guess; if the image is unclear or the information is insufficient, say so." +) + + +def _to_data_url(image_bytes: bytes, mime_type: str) -> str: + b64 = base64.b64encode(image_bytes).decode("ascii") + return f"data:{mime_type};base64,{b64}" + + +async def summarize_image( + *, + config: Configuration, + resources: ResourceManagerImpl, + image_bytes: bytes, + mime_type: str, +) -> str: + """Summarize an uploaded image using an OpenAI-compatible chat-completions model.""" + model_id = (config.image_summarization_model or "").strip() + if not model_id: + raise ConfigurationError( + "image_summarization_model is not configured, but an image was provided" + ) + + lm_confs = resources.config.resources.language_models + if model_id not in lm_confs.openai_chat_completions_language_model_confs: + raise ConfigurationError( + "image_summarization_model must reference an 'openai-chat-completions' " + f"language model id, got: {model_id!r}" + ) + + conf = lm_confs.get_openai_chat_completions_language_model_conf(model_id) + + client = openai.AsyncOpenAI( + api_key=conf.api_key.get_secret_value(), + base_url=conf.base_url, + ) + + data_url = _to_data_url(image_bytes, mime_type) + + print("Data URL:", data_url) # Debugging line to check the data URL + + messages = [ + {"role": "system", "content": _IMAGE_SUMMARY_SYSTEM_PROMPT}, + { + "role": "user", + "content": [ + {"type": "text", "text": _IMAGE_SUMMARY_USER_PROMPT}, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + }, + ] + + response = await client.chat.completions.create( + model=conf.model, + messages=messages, + temperature=0, + ) + + summary = (response.choices[0].message.content or "").strip() + if not summary: + logger.warning("Empty image summary returned by model '%s'", model_id) + return summary diff --git a/src/memmachine/server/api_v2/router.py b/src/memmachine/server/api_v2/router.py index 011ac2c83..e6a31907d 100644 --- a/src/memmachine/server/api_v2/router.py +++ b/src/memmachine/server/api_v2/router.py @@ -1,12 +1,23 @@ """API v2 router for MemMachine project and memory management endpoints.""" +import json import logging import traceback from typing import Annotated -from fastapi import APIRouter, Depends, FastAPI, Response +from fastapi import ( + APIRouter, + Depends, + FastAPI, + File, + Form, + Request, + Response, + UploadFile, +) from fastapi.exceptions import HTTPException, RequestValidationError from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from pydantic import ValidationError from memmachine import MemMachine from memmachine.common.api.doc import RouterDoc @@ -41,6 +52,7 @@ SessionNotFoundError, ) from memmachine.main.memmachine import ALL_MEMORY_TYPES +from memmachine.server.api_v2.image_summarization import summarize_image from memmachine.server.api_v2.service import ( _add_messages_to, _list_target_memories, @@ -146,6 +158,42 @@ def format_validation_error_message(exc: RequestValidationError) -> str: router = APIRouter() +async def _parse_add_memories_request( + request: Request, + spec: Annotated[str | None, Form()] = None, + image: Annotated[UploadFile | None, File()] = None, +) -> tuple[AddMemoriesSpec, UploadFile | None]: + """Parse AddMemories request from either JSON body or multipart form-data.""" + content_type = request.headers.get("content-type", "") + + if spec is not None: + try: + raw = json.loads(spec) + except json.JSONDecodeError as e: + raise RestError(code=422, message="Invalid request payload: spec is not valid JSON", ex=e) from e + try: + return AddMemoriesSpec(**raw), image + except ValidationError as e: + raise RestError(code=422, message="Invalid request payload", ex=e) from e + + # Multipart requests must send spec explicitly + if "multipart/form-data" in content_type: + raise RestError( + code=422, + message="Invalid request payload: missing form field 'spec' for multipart request", + ) + + # Default: JSON body + try: + raw = await request.json() + except Exception as e: + raise RestError(code=422, message="Invalid request payload", ex=e) from e + try: + return AddMemoriesSpec(**raw), None + except ValidationError as e: + raise RestError(code=422, message="Invalid request payload", ex=e) from e + + @router.post("/projects", status_code=201, description=RouterDoc.CREATE_PROJECT) async def create_project( spec: CreateProjectSpec, @@ -274,10 +322,46 @@ async def delete_project( @router.post("/memories", description=RouterDoc.ADD_MEMORIES) async def add_memories( - spec: AddMemoriesSpec, + parsed: Annotated[ + tuple[AddMemoriesSpec, UploadFile | None], + Depends(_parse_add_memories_request), + ], memmachine: Annotated[MemMachine, Depends(get_memmachine)], ) -> AddMemoriesResponse: """Add memories to a project.""" + spec, image = parsed + + if image is not None: + # Ambiguity: how to attach one image to multiple messages. + # For now, require a single message. + if len(spec.messages) != 1: + raise RestError( + code=422, + message=( + "Invalid request payload: image upload is only supported when messages has exactly 1 item" + ), + ) + + image_bytes = await image.read() + if not image_bytes: + raise RestError(code=422, message="Invalid request payload: image file is empty") + + mime_type = image.content_type or "application/octet-stream" + try: + summary = await summarize_image( + config=memmachine.config, + resources=memmachine.resources, + image_bytes=image_bytes, + mime_type=mime_type, + ) + except Exception as e: + raise RestError(code=500, message="Unable to summarize image", ex=e) from e + + if summary: + spec.messages[0].content = ( + f"{spec.messages[0].content}\n\n[Image Summary]\n{summary}" + ) + # Use types from spec if provided, otherwise use all memory types target_memories = spec.types if spec.types else ALL_MEMORY_TYPES results = await _add_messages_to( diff --git a/tests/memmachine/server/api_v2/test_router.py b/tests/memmachine/server/api_v2/test_router.py index ebbdd32dc..3bae45057 100644 --- a/tests/memmachine/server/api_v2/test_router.py +++ b/tests/memmachine/server/api_v2/test_router.py @@ -21,6 +21,8 @@ @pytest.fixture def mock_memmachine(): memmachine = AsyncMock() + memmachine.config = MagicMock(image_summarization_model="qwen_model") + memmachine.resources = MagicMock() return memmachine @@ -238,6 +240,7 @@ def test_add_memories(client, mock_memmachine): response = client.post("/api/v2/memories", json=payload) assert response.status_code == 200 assert response.json() == {"results": [{"uid": "123"}]} + call_args = mock_add_messages.call_args[1] assert call_args["target_memories"] == [MemoryType.Episodic] @@ -252,6 +255,40 @@ def test_add_memories(client, mock_memmachine): assert call_args["target_memories"] == [MemoryType.Semantic] +def test_add_memories_multipart_with_image(client): + payload = { + "org_id": "test_org", + "project_id": "test_proj", + "messages": [{"role": "user", "content": "hello"}], + } + + with ( + patch("memmachine.server.api_v2.router._add_messages_to") as mock_add_messages, + patch("memmachine.server.api_v2.router.summarize_image") as mock_summarize, + ): + mock_add_messages.return_value = [{"status": "ok", "uid": "123"}] + mock_summarize.return_value = "a cat on a sofa" + + response = client.post( + "/api/v2/memories", + data={"spec": __import__("json").dumps(payload)}, + files={"image": ("test.png", b"fake", "image/png")}, + ) + + assert response.status_code == 200 + assert response.json() == {"results": [{"uid": "123"}]} + mock_summarize.assert_awaited_once() + + +def test_add_memories_multipart_missing_spec(client): + response = client.post( + "/api/v2/memories", + data={}, + files={"image": ("test.png", b"fake", "image/png")}, + ) + assert response.status_code == 422 + + def test_add_memories_episode_type_forwarded(client, mock_memmachine): payload = { "org_id": "test_org", From 526ea083c8e6931fa58c736a4f17db34e560ff1a Mon Sep 17 00:00:00 2001 From: mwqgithub Date: Thu, 29 Jan 2026 15:29:32 +0800 Subject: [PATCH 2/3] support mcp --- .../server/api_v2/image_summarization.py | 2 -- src/memmachine/server/api_v2/mcp.py | 25 +++++++++++++- tests/memmachine/server/api_v2/test_mcp.py | 34 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/memmachine/server/api_v2/image_summarization.py b/src/memmachine/server/api_v2/image_summarization.py index f3f69a342..c36e0e08c 100644 --- a/src/memmachine/server/api_v2/image_summarization.py +++ b/src/memmachine/server/api_v2/image_summarization.py @@ -60,8 +60,6 @@ async def summarize_image( data_url = _to_data_url(image_bytes, mime_type) - print("Data URL:", data_url) # Debugging line to check the data URL - messages = [ {"role": "system", "content": _IMAGE_SUMMARY_SYSTEM_PROMPT}, { diff --git a/src/memmachine/server/api_v2/mcp.py b/src/memmachine/server/api_v2/mcp.py index cd12691c0..7bf8f88be 100644 --- a/src/memmachine/server/api_v2/mcp.py +++ b/src/memmachine/server/api_v2/mcp.py @@ -1,6 +1,8 @@ """MCP tool implementations for MemMachine.""" import contextvars +import base64 +import binascii import logging import os import uuid @@ -30,6 +32,7 @@ from memmachine.common.configuration import Configuration from memmachine.common.resource_manager.resource_manager import ResourceManagerImpl from memmachine.main.memmachine import ALL_MEMORY_TYPES, MemMachine +from memmachine.server.api_v2.image_summarization import summarize_image from memmachine.server.api_v2.service import ( _add_messages_to, _delete_memories, @@ -420,6 +423,8 @@ async def mcp_add_memory( org_id: str = "", proj_id: str = "", user_id: str = "", + image_base64: str = "", + image_mime_type: str = "image/jpeg", ) -> McpResponse: """ Add a new memory for the specified user. @@ -437,6 +442,8 @@ async def mcp_add_memory( proj_id: The project ID (optional, flat style). user_id: The unique identifier of the user (flat style). content: The complete context or summary to store in memory (flat style). + image_base64: Optional base64-encoded image bytes (no data URL prefix). + image_mime_type: MIME type for the uploaded image (e.g. 'image/jpeg', 'image/png'). Returns: McpResponse indicating success or failure. @@ -449,12 +456,28 @@ async def mcp_add_memory( message="MemMachine is not initialized", ) try: + merged_content = content + if image_base64: + try: + image_bytes = base64.b64decode(image_base64, validate=True) + except (binascii.Error, ValueError) as e: + raise ValueError("image_base64 is not valid base64") from e + + summary = await summarize_image( + config=mem_machine.config, + resources=mem_machine.resources, + image_bytes=image_bytes, + mime_type=(image_mime_type or "image/jpeg"), + ) + if summary: + merged_content = f"{content}\n\n[Image Summary]\n{summary}" + param = Params( org_id=org_id, proj_id=proj_id, user_id=user_id, ) - spec = param.to_add_memories_spec(content) + spec = param.to_add_memories_spec(merged_content) await _add_messages_to( target_memories=ALL_MEMORY_TYPES, spec=spec, memmachine=mem_machine ) diff --git a/tests/memmachine/server/api_v2/test_mcp.py b/tests/memmachine/server/api_v2/test_mcp.py index 78fc5e0fc..a28a211ea 100644 --- a/tests/memmachine/server/api_v2/test_mcp.py +++ b/tests/memmachine/server/api_v2/test_mcp.py @@ -164,6 +164,8 @@ def patch_memmachine(): import memmachine.server.api_v2.mcp as mcp_module mcp_module.mem_machine = Mock() + mcp_module.mem_machine.config = Mock(image_summarization_model="qwen_model") + mcp_module.mem_machine.resources = Mock() yield mcp_module.mem_machine = None # cleanup @@ -187,6 +189,38 @@ async def test_add_memory_success(mock_add, params, mcp_client): assert root.message == "Success" +@pytest.mark.asyncio +@patch("memmachine.server.api_v2.mcp.summarize_image", new_callable=AsyncMock) +@patch("memmachine.server.api_v2.mcp._add_messages_to", new_callable=AsyncMock) +async def test_add_memory_with_image_success(mock_add, mock_summarize, params, mcp_client): + mock_summarize.return_value = "a cat on a sofa" + + # base64("fake") + image_b64 = "ZmFrZQ==" + + result = await mcp_client.call_tool( + name="add_memory", + arguments={ + "content": "hello memory", + "org_id": params.org_id, + "proj_id": params.proj_id, + "user_id": params.user_id, + "image_base64": image_b64, + "image_mime_type": "image/png", + }, + ) + + mock_summarize.assert_awaited_once() + mock_add.assert_awaited_once() + call_kwargs = mock_add.call_args.kwargs + spec = call_kwargs["spec"] + assert "[Image Summary]" in spec.messages[0].content + assert "a cat on a sofa" in spec.messages[0].content + + assert result.data is not None + assert result.data.status == 200 + + @pytest.mark.asyncio @patch("memmachine.server.api_v2.mcp._add_messages_to", new_callable=AsyncMock) async def test_add_memory_failure(mock_add, params, mcp_client): From d14c035ce894f47367222df3e8407b3940614ba3 Mon Sep 17 00:00:00 2001 From: Andrew Tian Date: Mon, 2 Feb 2026 17:15:19 +0800 Subject: [PATCH 3/3] feat: Support multimodal client --- src/memmachine/rest_client/memory.py | 84 +++++++++++++++++-- src/memmachine/server/api_v2/router.py | 5 +- tests/memmachine/rest_client/test_memory.py | 69 +++++++++++++++ tests/memmachine/server/api_v2/test_router.py | 23 +++++ 4 files changed, 173 insertions(+), 8 deletions(-) diff --git a/src/memmachine/rest_client/memory.py b/src/memmachine/rest_client/memory.py index 4cd4b3e28..4948455e4 100644 --- a/src/memmachine/rest_client/memory.py +++ b/src/memmachine/rest_client/memory.py @@ -8,8 +8,11 @@ from __future__ import annotations import builtins +import io +import json import logging from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING, Any import requests @@ -74,6 +77,10 @@ class Memory: # Search memories (filters based on metadata are automatically applied) results = memory.search("What do I like to eat?") + + # Add memory with an image (server summarizes image and appends to content) + memory.add("Screenshot of the dashboard", image="/path/to/screenshot.png") + memory.add("Photo from meeting", image=open("meeting.jpg", "rb"), image_mime_type="image/jpeg") ``` """ @@ -226,10 +233,15 @@ def add( metadata: dict[str, str] | None = None, timestamp: datetime | None = None, timeout: int | None = None, + image: bytes | str | Path | None = None, + image_mime_type: str | None = None, ) -> builtins.list[AddMemoryResult]: """ Add a memory episode. + Optionally attach an image: the server will summarize it with a vision + model and append the summary to the message content before storing. + Args: content: The content to store in memory role: Message role - "user", "assistant", or "system" (default: "") @@ -240,6 +252,13 @@ def add( metadata: Additional metadata for the episode timestamp: Optional timestamp for the memory. If not provided, server will use current UTC time. timeout: Request timeout in seconds (uses client default if not provided) + image: Optional image to attach. Can be raw bytes, a file path (str or Path), + or a file-like object with a read() method. When provided, the server + summarizes the image and appends it to content (requires server config + image_summarization_model). + image_mime_type: MIME type for the image (e.g. "image/jpeg", "image/png"). + Used when image is bytes or a file-like; ignored when image + is a path (guessed from extension). Defaults to "image/jpeg". Returns: List of AddMemoryResult objects containing UID results from the server. @@ -248,6 +267,8 @@ def add( Raises: requests.RequestException: If the request fails RuntimeError: If the client has been closed + FileNotFoundError: If image is a path that does not exist + TypeError: If image has an unsupported type """ if memory_types is None: @@ -290,12 +311,28 @@ def add( ) v2_data = spec.model_dump(mode="json", exclude_unset=True) - response = self.client.request( - "POST", - f"{self.client.base_url}/api/v2/memories", - json=v2_data, - timeout=timeout, - ) + image_payload = self._normalize_image(image, image_mime_type) + if image_payload is not None: + image_bytes, mime_type = image_payload + # Multipart: spec as form field, image as file (server requires exactly one message) + data = {"spec": json.dumps(v2_data)} + files = { + "image": ("image", io.BytesIO(image_bytes), mime_type), + } + response = self.client.request( + "POST", + f"{self.client.base_url}/api/v2/memories", + data=data, + files=files, + timeout=timeout, + ) + else: + response = self.client.request( + "POST", + f"{self.client.base_url}/api/v2/memories", + json=v2_data, + timeout=timeout, + ) response.raise_for_status() response_data = response.json() @@ -650,6 +687,41 @@ def get_default_filter_dict(self) -> dict[str, str]: return default_filter + def _normalize_image( + self, + image: bytes | str | Path | None, + image_mime_type: str | None, + ) -> tuple[bytes, str] | None: + """Normalize image input to (bytes, mime_type). Returns None if image is None.""" + if image is None: + return None + mime = (image_mime_type or "").strip() or "image/jpeg" + if isinstance(image, bytes): + return (image, mime) + if isinstance(image, (str, Path)): + path = Path(image) + if not path.is_file(): + raise FileNotFoundError(f"Image file not found: {path}") + raw = path.read_bytes() + if not (image_mime_type or "").strip(): + suffix = path.suffix.lower() + mime = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }.get(suffix, "image/jpeg") + return (raw, mime) + if hasattr(image, "read"): + raw = image.read() + if isinstance(raw, str): + raw = raw.encode("utf-8") + return (raw, mime) + raise TypeError( + "image must be None, bytes, a file path (str | Path), or a file-like object" + ) + def _dict_to_filter_string(self, filter_dict: dict[str, str]) -> str: """ Convert filter_dict to SQL-like filter string format: key='value' AND key='value'. diff --git a/src/memmachine/server/api_v2/router.py b/src/memmachine/server/api_v2/router.py index e6a31907d..dbf02c769 100644 --- a/src/memmachine/server/api_v2/router.py +++ b/src/memmachine/server/api_v2/router.py @@ -160,10 +160,11 @@ def format_validation_error_message(exc: RequestValidationError) -> str: async def _parse_add_memories_request( request: Request, - spec: Annotated[str | None, Form()] = None, image: Annotated[UploadFile | None, File()] = None, + spec: Annotated[str | None, Form()] = None, ) -> tuple[AddMemoriesSpec, UploadFile | None]: - """Parse AddMemories request from either JSON body or multipart form-data.""" + """Parse AddMemories request from either JSON body or multipart form-data (spec + image). + File() before Form() so FastAPI correctly parses multipart when both are present.""" content_type = request.headers.get("content-type", "") if spec is not None: diff --git a/tests/memmachine/rest_client/test_memory.py b/tests/memmachine/rest_client/test_memory.py index 6974c6a09..2710394f3 100644 --- a/tests/memmachine/rest_client/test_memory.py +++ b/tests/memmachine/rest_client/test_memory.py @@ -1,5 +1,6 @@ """Unit tests for Memory class (v2 API).""" +import json from unittest.mock import Mock import pytest @@ -329,6 +330,74 @@ def test_add_client_closed(self, mock_client): with pytest.raises(RuntimeError, match="client has been closed"): memory.add("Content") + def test_add_with_image_sends_multipart(self, mock_client): + """Add with image sends multipart (spec + image) to POST /api/v2/memories (same endpoint as JSON).""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.raise_for_status = Mock() + mock_response.json.return_value = {"results": [{"uid": "memory_123"}]} + mock_client.request.return_value = mock_response + + memory = Memory( + client=mock_client, + org_id="test_org", + project_id="test_project", + metadata={"user_id": "user1"}, + ) + image_bytes = b"\x89PNG\r\n\x1a\n" + result = memory.add( + "Screenshot of the dashboard", + image=image_bytes, + image_mime_type="image/png", + ) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].uid == "memory_123" + mock_client.request.assert_called_once() + call_kw = mock_client.request.call_args[1] + assert "json" not in call_kw + assert "data" in call_kw + assert "files" in call_kw + spec = json.loads(call_kw["data"]["spec"]) + assert spec["org_id"] == "test_org" + assert spec["project_id"] == "test_project" + assert len(spec["messages"]) == 1 + assert spec["messages"][0]["content"] == "Screenshot of the dashboard" + file_tuple = call_kw["files"]["image"] + assert file_tuple[0] == "image" + assert file_tuple[2] == "image/png" + assert file_tuple[1].read() == image_bytes + + def test_add_with_image_path_guesses_mime(self, mock_client, tmp_path): + """Test adding memory with image file path guesses MIME from extension.""" + (tmp_path / "photo.png").write_bytes(b"fake png") + mock_response = Mock() + mock_response.status_code = 200 + mock_response.raise_for_status = Mock() + mock_response.json.return_value = {"results": [{"uid": "mem_1"}]} + mock_client.request.return_value = mock_response + + memory = Memory( + client=mock_client, + org_id="o", + project_id="p", + ) + memory.add("Photo", image=str(tmp_path / "photo.png")) + + call_kw = mock_client.request.call_args[1] + assert call_kw["files"]["image"][2] == "image/png" + + def test_add_with_image_invalid_type_raises(self, mock_client): + """Test add with invalid image type raises TypeError.""" + memory = Memory( + client=mock_client, + org_id="test_org", + project_id="test_project", + ) + with pytest.raises(TypeError, match="image must be"): + memory.add("Text", image=123) + def test_search_success(self, mock_client): """Test successful memory search with v2 API format.""" mock_response = Mock() diff --git a/tests/memmachine/server/api_v2/test_router.py b/tests/memmachine/server/api_v2/test_router.py index 3bae45057..b5f46b885 100644 --- a/tests/memmachine/server/api_v2/test_router.py +++ b/tests/memmachine/server/api_v2/test_router.py @@ -256,6 +256,7 @@ def test_add_memories(client, mock_memmachine): def test_add_memories_multipart_with_image(client): + """POST /api/v2/memories supports image via multipart (spec + image); same endpoint as JSON.""" payload = { "org_id": "test_org", "project_id": "test_proj", @@ -281,6 +282,7 @@ def test_add_memories_multipart_with_image(client): def test_add_memories_multipart_missing_spec(client): + """Multipart without 'spec' field returns 422.""" response = client.post( "/api/v2/memories", data={}, @@ -289,6 +291,27 @@ def test_add_memories_multipart_missing_spec(client): assert response.status_code == 422 +def test_add_memories_multipart_image_requires_single_message(client): + """When image is present, spec must have exactly one message; otherwise 422.""" + payload = { + "org_id": "test_org", + "project_id": "test_proj", + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ], + } + response = client.post( + "/api/v2/memories", + data={"spec": __import__("json").dumps(payload)}, + files={"image": ("test.png", b"fake", "image/png")}, + ) + assert response.status_code == 422 + detail = response.json().get("detail") + msg = detail.get("message", detail) if isinstance(detail, dict) else detail + assert "exactly 1" in str(msg) + + def test_add_memories_episode_type_forwarded(client, mock_memmachine): payload = { "org_id": "test_org",