feat: add multipart memory uploads

This commit is contained in:
2026-06-22 15:53:29 +08:00
parent 12c767cd68
commit f77454b4cc
6 changed files with 1076 additions and 1255 deletions

1152
README.md

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,9 @@ from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit
import httpx
from fastapi import APIRouter, FastAPI, File, Form, HTTPException, Request, UploadFile
from pydantic import ValidationError
from pydantic import BaseModel, Field, field_validator
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.responses import Response
from .config import GatewayConfig
@ -220,6 +222,60 @@ def _backend_http_error_detail(exc: httpx.HTTPStatusError) -> Any:
return exc.response.text
def _form_text(form: Any, field: str, default: str | None = None) -> str:
value = form.get(field)
if value is None:
if default is not None:
return default
raise HTTPException(status_code=422, detail=f"missing form field: {field}")
if isinstance(value, StarletteUploadFile):
raise HTTPException(status_code=422, detail=f"form field must be text: {field}")
return str(value)
async def _form_json_text(form: Any, field: str) -> str:
value = form.get(field)
if value is None:
raise HTTPException(status_code=422, detail=f"missing form field: {field}")
if isinstance(value, StarletteUploadFile):
raw = await value.read()
return raw.decode("utf-8")
return str(value)
def _upload_files_from_form(form: Any) -> dict[str, UploadFile]:
files: dict[str, UploadFile] = {}
for key, value in form.multi_items():
if not isinstance(value, StarletteUploadFile):
continue
if key == "messages":
continue
if key in files:
raise HTTPException(
status_code=422,
detail=f"duplicate upload file field: {key}",
)
files[key] = value
return files
async def _multipart_messages(form: Any) -> list[dict[str, Any]]:
raw = await _form_json_text(form, "messages")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400,
detail=f"invalid messages JSON: {exc.msg}",
) from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="messages must be a JSON array")
try:
return [AddMemoryMessage.model_validate(item).model_dump() for item in parsed]
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
def create_app(
*,
config: GatewayConfig | None = None,
@ -466,6 +522,33 @@ def create_app(
except InvalidAttachment as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/memories/add/multipart")
async def add_memory_multipart(request: Request) -> dict[str, Any]:
form = await request.form()
user_id = _form_text(form, "user_id")
user_key = _form_text(form, "user_key")
require_user(user_id, user_key)
try:
return await service.add_memory_with_uploads(
user_id=user_id,
session_id=_form_text(form, "session_id"),
app_id=_form_text(form, "app_id", "default"),
project_id=_form_text(form, "project_id", "default"),
messages=await _multipart_messages(form),
upload_files=_upload_files_from_form(form),
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=_backend_http_error_detail(exc),
) from exc
except UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except UnsupportedContentType as exc:
raise HTTPException(status_code=415, detail=str(exc)) from exc
except InvalidAttachment as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/memories/flush")
async def flush_memory(
request: FlushMemoryRequest,

View File

@ -138,6 +138,25 @@ def _remove_empty_parents(path: Path, stop_at: Path | None = None) -> None:
current = parent
def _read_upload_bytes(
file: UploadFile,
max_upload_bytes: int,
) -> tuple[bytes, str, int]:
sha256 = hashlib.sha256()
size = 0
chunks: list[bytes] = []
while True:
chunk = file.file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > max_upload_bytes:
raise UploadTooLarge(f"upload exceeds max size of {max_upload_bytes} bytes")
sha256.update(chunk)
chunks.append(chunk)
return b"".join(chunks), sha256.hexdigest(), size
class MemoryGatewayService:
def __init__(
self,
@ -617,6 +636,41 @@ class MemoryGatewayService:
raise
return {"session_id": session_id, "backend": backend}
async def add_memory_with_uploads(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
messages: list[dict[str, Any]],
upload_files: dict[str, UploadFile],
) -> dict[str, Any]:
messages, attachments, generated_paths = self._prepare_uploaded_memory_files(
user_id=user_id,
session_id=session_id,
app_id=app_id,
project_id=project_id,
messages=messages,
upload_files=upload_files,
)
payload = {
"session_id": session_id,
"app_id": app_id,
"project_id": project_id,
"messages": messages,
}
try:
backend = await self.backend_client.add_memory(payload)
for attachment in attachments:
self.repository.create_attachment(**attachment)
except Exception:
for path in generated_paths:
path.unlink(missing_ok=True)
_remove_empty_parents(path.parent, stop_at=self.config.storage_dir)
raise
return {"session_id": session_id, "backend": backend}
def _register_resource_attachment(
self,
resource: dict[str, Any],
@ -713,6 +767,119 @@ class MemoryGatewayService:
raise
return attachments, generated_paths
def _prepare_uploaded_memory_files(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
messages: list[dict[str, Any]],
upload_files: dict[str, UploadFile],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[Path]]:
attachments: list[dict[str, Any]] = []
generated_paths: list[Path] = []
used_upload_ids: set[str] = set()
try:
for message in messages:
content = message.get("content")
if not isinstance(content, list):
continue
for index, item in enumerate(content):
if not isinstance(item, dict) or "upload_id" not in item:
continue
upload_id = str(item.get("upload_id") or "").strip()
if not upload_id:
raise InvalidAttachment("upload_id must not be empty")
if upload_id in used_upload_ids:
raise InvalidAttachment(f"duplicate upload_id: {upload_id}")
file = upload_files.get(upload_id)
if file is None:
raise InvalidAttachment(
f"missing upload file for upload_id: {upload_id}"
)
used_upload_ids.add(upload_id)
content[index] = self._materialize_uploaded_content_item(
user_id=user_id,
session_id=session_id,
app_id=app_id,
project_id=project_id,
item=item,
file=file,
attachments=attachments,
generated_paths=generated_paths,
)
unused_upload_ids = sorted(set(upload_files) - used_upload_ids)
if unused_upload_ids:
raise InvalidAttachment(
f"unused upload file field: {unused_upload_ids[0]}"
)
except Exception:
for path in generated_paths:
path.unlink(missing_ok=True)
_remove_empty_parents(path.parent, stop_at=self.config.storage_dir)
raise
return messages, attachments, generated_paths
def _materialize_uploaded_content_item(
self,
*,
user_id: str,
session_id: str,
app_id: str,
project_id: str,
item: dict[str, Any],
file: UploadFile,
attachments: list[dict[str, Any]],
generated_paths: list[Path],
) -> dict[str, Any]:
name = _safe_filename(str(item.get("name") or file.filename or "upload.bin"))
mime_type = file.content_type or mimetypes.guess_type(name)[0]
if not _mime_allowed(mime_type, self.config.allowed_mime_types):
raise UnsupportedContentType(f"unsupported content type: {mime_type}")
content_type = normalize_content_type(
name,
mime_type,
str(item.get("type") or ""),
)
data, sha256, _size_bytes = _read_upload_bytes(
file,
self.config.max_upload_bytes,
)
path = self.config.storage_dir / user_id / "memory_attachments" / sha256 / name
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
generated_paths.append(path)
content_item = {
key: value for key, value in item.items() if key not in {"upload_id", "uri"}
}
content_item["type"] = content_type
content_item["name"] = name
content_item["ext"] = Path(name).suffix.lstrip(".") or content_item.get("ext")
if content_type == "text":
content_item.pop("base64", None)
content_item["text"] = data.decode("utf-8", errors="replace")
else:
content_item.pop("text", None)
content_item["base64"] = base64.b64encode(data).decode("ascii")
attachments.append(
{
"id": f"a_{uuid.uuid4().hex}",
"user_id": user_id,
"app_id": app_id,
"project_id": project_id,
"session_id": session_id,
"resource_id": None,
"content_type": content_type,
"name": name,
"internal_uri": path.resolve().as_uri(),
"source": "memory_add_upload",
"sha256": sha256,
}
)
return content_item
async def flush_memory(
self,
*,

View File

@ -136,6 +136,43 @@ $CLI flush-memory --session-id chat:c_456
`--messages` accepts either a JSON array string or a path to a JSON file. Always flush after all messages for the session have been added.
For local binary files that cannot be converted to base64 by the caller, use the
multipart API directly. Put an `upload_id` in the content item and send a file
field with the same name:
```bash
curl -X POST "$MEMORY_GATEWAY_BASE_URL/memories/add/multipart" \
-F user_id="$MEMORY_GATEWAY_USER_ID" \
-F user_key="$MEMORY_GATEWAY_USER_KEY" \
-F session_id=chat:c_456 \
-F app_id=default \
-F project_id=default \
-F 'messages=[
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1781172177000,
"content": [
{"type": "text", "text": "Remember this image"},
{
"type": "image",
"upload_id": "image_1",
"name": "image.png",
"ext": "png"
}
]
}
]' \
-F 'image_1=@./image.png;type=image/png'
```
The multipart endpoint appends messages to the provided chat session. It stores
the uploaded file under Gateway storage, forwards text/base64 content to the
upstream memory service, and records an attachment mapping. Call `flush-memory`
afterward when the session should be extracted and indexed. This differs from
`upload-resource`, which creates an independent `resource:{user_id}:{resource_id}`
session and automatically performs add plus flush for resource searches.
### Override and Delete Memory
Use IDs from a search result:
@ -188,6 +225,8 @@ Common content items:
```
Prefer base64 for local binary files. A `file://` URI is only usable when upstream memory service can access the same filesystem path.
If that shared path guarantee is not true, use `/memories/add/multipart`,
`upload-resource`, or `/resources/external`.
## Search Scopes

View File

@ -1,144 +1,126 @@
# Memory Gateway multimodal API test
# Memory Gateway API curl examples
This file records a real end-to-end test through **Memory Gateway**, not direct upstream memory service calls.
This file keeps only the concrete API curl shapes and short notes. Replace
`<USER_KEY>` with the key returned by `POST /users`.
Gateway URL used by curl:
Base URL used in the live deployment test:
```text
http://127.0.0.1:8010
```
Gateway upstream memory service:
Test files:
```text
http://10.6.80.123:1995
tests/simple-multimodal-image.png
tests/simple-tone.wav
```
Test assets:
```text
/home/tom/memory-gateway/tests/simple-multimodal-image.png
/home/tom/memory-gateway/tests/simple-tone.wav
```
Asset check:
```text
tests/simple-multimodal-image.png: PNG image data, 96 x 64, 8-bit/color RGB, non-interlaced
tests/simple-tone.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz
```
## Start Gateway
Command:
## 1. Health
```bash
cd /home/tom/memory-gateway
MEMORY_GATEWAY_BACKEND_BASE_URL=http://10.6.80.123:1995 \
MEMORY_GATEWAY_DB_PATH=/tmp/memory_gateway_curl.sqlite3 \
MEMORY_GATEWAY_STORAGE_DIR=/tmp/memory_gateway_curl_storage \
MEMORY_GATEWAY_HOST=127.0.0.1 \
MEMORY_GATEWAY_PORT=8010 \
.venv/bin/python main.py
curl -sS http://127.0.0.1:8010/health
```
Observed startup:
```text
INFO: Started server process [771099]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8010 (Press CTRL+C to quit)
```
## 1. Create Gateway user
Request:
```bash
USER_ID="gateway_user_20260611180257"
curl -sS --location 'http://127.0.0.1:8010/users' \
--header 'Content-Type: application/json' \
--data "{
\"user_id\": \"${USER_ID}\"
}"
```
Response:
Expected shape:
```json
{
"user_id": "gateway_user_20260611180257",
"user_key": "uk_REDACTED",
"created_at": "2026-06-11T10:02:57.435437+00:00"
"status": "ok",
"api": {"status": "ok"},
"backend": {
"status": "ok",
"base_url": "http://0.0.0.0:1995",
"data": {"status": "ok"}
}
}
```
HTTP metadata:
## 2. Create user
```text
HTTP_STATUS:200
TOTAL_TIME:0.022431
```bash
curl -sS -X POST http://127.0.0.1:8010/users \
-H 'Content-Type: application/json' \
-d '{"user_id":"gateway_demo_user"}'
```
## 2. Add text + audio(base64) + image(file) through Gateway
Expected shape:
```json
{
"user_id": "gateway_demo_user",
"user_key": "uk_REDACTED",
"created_at": "2026-06-22T06:54:35.823262+00:00"
}
```
Use the returned `user_key` in later requests.
## 3. Add chat memory with multipart files
Use this when files belong to a chat/session message and the client should not
or cannot convert the files to base64.
`upload_id` rules:
- `upload_id` is defined by the caller.
- Gateway does not generate it.
- Gateway does not require a format such as `user_id_filetype_number`.
- It only needs to be non-empty, unique inside the request, and equal to the
multipart file field name.
- Good simple values are `image_1`, `image_2`, `audio_1`, `doc_1`.
In the `messages` JSON, `upload_id: "image_1"` points to this file field:
```bash
-F 'image_1=@tests/simple-multimodal-image.png;type=image/png'
```
Request:
```bash
cd /home/tom/memory-gateway
USER_KEY="uk_REDACTED"
CONVERSATION_ID="gateway-multimodal-20260611180257"
SESSION_ID="chat:${CONVERSATION_ID}"
TIMESTAMP_MS="1781172177000"
AUDIO_BASE64="$(base64 -w0 tests/simple-tone.wav)"
curl -sS --location 'http://127.0.0.1:8010/memories/add' \
--header 'Content-Type: application/json' \
--data "{
\"user_id\": \"${USER_ID}\",
\"user_key\": \"${USER_KEY}\",
\"session_id\": \"${SESSION_ID}\",
\"app_id\": \"default\",
\"project_id\": \"default\",
\"messages\": [
{
\"sender_id\": \"${USER_ID}\",
\"role\": \"user\",
\"timestamp\": ${TIMESTAMP_MS},
\"content\": [
{
\"type\": \"text\",
\"text\": \"请通过 Memory Gateway 同时记住这段文字、音频和图片:图片里有左侧红色方块、右侧蓝色圆形、底部绿色横条;音频是一段短促的测试音。以后可能会问图片中各个物体的位置和颜色。\"
},
{
\"type\": \"audio\",
\"base64\": \"${AUDIO_BASE64}\",
\"ext\": \"wav\",
\"name\": \"simple-tone.wav\"
},
{
\"type\": \"image\",
\"uri\": \"file:///home/tom/memory-gateway/tests/simple-multimodal-image.png\",
\"ext\": \"png\",
\"name\": \"simple-multimodal-image.png\"
}
]
}
]
}"
curl -sS -X POST http://127.0.0.1:8010/memories/add/multipart \
-F 'user_id=gateway_demo_user' \
-F 'user_key=<USER_KEY>' \
-F 'session_id=chat:gateway_demo_conversation' \
-F 'app_id=default' \
-F 'project_id=default' \
-F 'messages=[
{
"sender_id": "gateway_demo_user",
"role": "user",
"timestamp": 1782111275810,
"content": [
{
"type": "text",
"text": "请记住这次上传:图片里有左上红色方块、右上蓝色圆形、底部绿色横条;音频是一段短促测试音。"
},
{
"type": "image",
"upload_id": "image_1",
"name": "simple-multimodal-image.png",
"ext": "png"
},
{
"type": "audio",
"upload_id": "audio_1",
"name": "simple-tone.wav",
"ext": "wav"
}
]
}
]' \
-F 'image_1=@tests/simple-multimodal-image.png;type=image/png' \
-F 'audio_1=@tests/simple-tone.wav;type=audio/wav'
```
Response:
Expected shape:
```json
{
"session_id": "chat:gateway-multimodal-20260611180257",
"session_id": "chat:gateway_demo_conversation",
"backend": {
"request_id": "c9e24b8d27ee4ad08a8df70273336637",
"request_id": "0d6451f4077040e4af207cc6b034ea34",
"data": {
"message_count": 1,
"status": "accumulated"
@ -147,308 +129,153 @@ Response:
}
```
HTTP metadata:
Gateway stores the uploaded files and forwards upstream-compatible `base64` or
`text` content. The client does not send `file://` and does not send base64.
```text
HTTP_STATUS:200
TOTAL_TIME:1.552665
```
Common errors:
## 3. Flush through Gateway
- Missing file field for an `upload_id`: `422`
- Duplicate `upload_id`: `422`
- Extra uploaded file field not referenced by `messages`: `422`
- Unsupported MIME type: `415`
- File too large: `413`
Request:
## 4. Flush chat session
`/memories/add/multipart` only appends messages. Call flush when the session
should be extracted and indexed.
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/flush' \
--header 'Content-Type: application/json' \
--data "{
\"user_id\": \"${USER_ID}\",
\"user_key\": \"${USER_KEY}\",
\"session_id\": \"${SESSION_ID}\",
\"app_id\": \"default\",
\"project_id\": \"default\"
}"
curl -sS -X POST http://127.0.0.1:8010/memories/flush \
-H 'Content-Type: application/json' \
-d '{
"user_id": "gateway_demo_user",
"user_key": "<USER_KEY>",
"session_id": "chat:gateway_demo_conversation",
"app_id": "default",
"project_id": "default"
}'
```
Response:
Expected shape:
```json
{
"session_id": "chat:gateway-multimodal-20260611180257",
"session_id": "chat:gateway_demo_conversation",
"backend": {
"request_id": "8eb7d5db2d3b43f4999f445aabb813b1",
"data": {
"status": "extracted"
}
"request_id": "4df5415115a34f109c564abd2f9012c6",
"data": {"status": "extracted"}
}
}
```
HTTP metadata:
```text
HTTP_STATUS:200
TOTAL_TIME:2.135721
```
## 4. Search through Gateway
upstream memory service indexing can lag briefly after `flush`, so this test waited about 2 seconds before searching.
Request:
## 5. Search chat session
```bash
sleep 2
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data "{
\"user_id\": \"${USER_ID}\",
\"user_key\": \"${USER_KEY}\",
\"conversation_id\": \"${CONVERSATION_ID}\",
\"query\": \"图片里的蓝色圆形在哪里?音频是什么?\",
\"scope\": [\"current_chat\"],
\"top_k\": 5,
\"app_id\": \"default\",
\"project_id\": \"default\"
}"
curl -sS -X POST http://127.0.0.1:8010/memories/search \
-H 'Content-Type: application/json' \
-d '{
"user_id": "gateway_demo_user",
"user_key": "<USER_KEY>",
"conversation_id": "gateway_demo_conversation",
"query": "图片里的蓝色圆形在哪里?底部是什么颜色的横条?",
"scope": ["current_chat"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
Response:
Expected result excerpt:
```json
{
"results": [
{
"id": "gateway_user_20260611180257_ep_20260611_00000001",
"session_id": "chat:gateway-multimodal-20260611180257",
"text": "On June 11, 2026 at 10:02 AM UTC, user gateway_user_20260611180257 uploaded a multimodal memory package via Memory Gateway. The package included an image file named simple-multimodal-image.png and a short test audio clip. The image displayed three geometric shapes on a light gray background: a solid red square in the upper-left, a solid blue circle in the upper-right (horizontally aligned with the square), and a long, thin green horizontal rectangle spanning the bottom below both shapes. The user instructed the system to retain these details, anticipating future queries regarding the objects' positions and colors.",
"score": 0.6069304347038269,
"session_id": "chat:gateway_demo_conversation",
"source_scope": "current_chat",
"resource_id": null,
"resource_uri": null,
"raw": {
"id": "gateway_user_20260611180257_ep_20260611_00000001",
"user_id": "gateway_user_20260611180257",
"app_id": "default",
"project_id": "default",
"session_id": "chat:gateway-multimodal-20260611180257",
"timestamp": "2026-06-11T10:02:57Z",
"sender_ids": [
"gateway_user_20260611180257"
],
"summary": "On June 11, 2026 at 10:02 AM UTC, user gateway_user_20260611180257 uploaded a multimodal memory package via Memory Gateway. The package included an image file named simple-multimodal-image.png and a s",
"subject": "gateway_user_20260611180257 Multimodal Memory Upload June 11, 2026",
"episode": "On June 11, 2026 at 10:02 AM UTC, user gateway_user_20260611180257 uploaded a multimodal memory package via Memory Gateway. The package included an image file named simple-multimodal-image.png and a short test audio clip. The image displayed three geometric shapes on a light gray background: a solid red square in the upper-left, a solid blue circle in the upper-right (horizontally aligned with the square), and a long, thin green horizontal rectangle spanning the bottom below both shapes. The user instructed the system to retain these details, anticipating future queries regarding the objects' positions and colors.",
"type": "Conversation",
"score": 0.6069304347038269,
"atomic_facts": [
{
"id": "gateway_user_20260611180257_af_20260611_00000004",
"content": "gateway_user_20260611180257 stated that questions about the positions and colors of the objects in the image might be asked in the future.",
"score": 0.6069304347038269
}
]
}
"text": "The image contained a red square, a blue circle, and a green horizontal rectangle.",
"attachments": [
{
"type": "image",
"name": "simple-multimodal-image.png",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
},
{
"type": "audio",
"name": "simple-tone.wav",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
}
]
}
]
}
```
HTTP metadata:
## 6. Upload an independent resource
```text
HTTP_STATUS:200
TOTAL_TIME:0.064128
```
# Other Memory Gateway API tests
The following calls used a temporary Gateway database and storage directory. All requests target Memory Gateway at `http://127.0.0.1:8010`.
## 5. Health
Request:
Use `/resources` when the file is an independent resource, not just an
attachment inside one chat message.
```bash
curl -sS --location 'http://127.0.0.1:8010/health'
curl -sS -X POST http://127.0.0.1:8010/resources \
-F 'user_id=gateway_demo_user' \
-F 'user_key=<USER_KEY>' \
-F 'app_id=default' \
-F 'project_id=default' \
-F 'title=Gateway demo image resource' \
-F 'description=Demo upload for simple multimodal image' \
-F 'file=@tests/simple-multimodal-image.png;type=image/png'
```
Response:
Expected shape:
```json
{
"status": "ok",
"api": {"status": "ok"},
"backend": {
"status": "ok",
"base_url": "http://10.6.80.123:1995",
"data": {"status": "ok"}
}
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.034914
```
## 6. Invalid credentials
Request:
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources?user_id=other_api_20260612095541&user_key=wrong-key'
```
Response:
```json
{"detail":"invalid user credentials"}
```
```text
HTTP_STATUS:401
TOTAL_TIME:0.001447
```
## 7. Upload resource
The temporary test user was created with:
```bash
curl -sS --location 'http://127.0.0.1:8010/users' \
--header 'Content-Type: application/json' \
--data '{"user_id":"other_api_20260612095541"}'
```
User response:
```json
{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"created_at": "2026-06-12T01:55:41.448076+00:00"
}
```
Upload request:
```bash
cd /home/tom/memory-gateway
curl -sS --location 'http://127.0.0.1:8010/resources' \
--form 'user_id=other_api_20260612095541' \
--form 'user_key=uk_REDACTED' \
--form 'app_id=default' \
--form 'project_id=default' \
--form 'title=Gateway API image resource' \
--form 'description=Resource lifecycle test through Memory Gateway' \
--form 'file=@tests/simple-multimodal-image.png;type=image/png'
```
Response:
```json
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"session_id": "resource:gateway_demo_user:r_1678eacf3e8c49f9a8863454c5b35e68",
"uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"status": "extracted"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:4.700296
```
Unlike `/memories/add/multipart`, `/resources` automatically calls upstream add
and flush.
## 8. List resources
Request:
## 7. List resources
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources?user_id=other_api_20260612095541&user_key=uk_REDACTED'
curl -sS \
'http://127.0.0.1:8010/resources?user_id=gateway_demo_user&user_key=<USER_KEY>'
```
Response:
Expected shape:
```json
{
"resources": [
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"user_id": "other_api_20260612095541",
"resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"filename": "simple-multimodal-image.png",
"content_type": "image",
"mime_type": "image/png",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"status": "extracted",
"title": "Gateway API image resource",
"description": "Resource lifecycle test through Memory Gateway",
"created_at": "2026-06-12T01:55:41.527716+00:00",
"updated_at": "2026-06-12T01:55:46.204082+00:00"
"uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"session_id": "resource:gateway_demo_user:r_1678eacf3e8c49f9a8863454c5b35e68",
"status": "extracted"
}
]
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001785
```
## 9. Resource detail
Request:
## 8. Search resources
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources/r_2700e435f72a49e6a7f736d17f8c7ac7?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
Response:
```json
{
"resources": [
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"user_id": "other_api_20260612095541",
"filename": "simple-multimodal-image.png",
"content_type": "image",
"mime_type": "image/png",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"status": "extracted",
"title": "Gateway API image resource",
"description": "Resource lifecycle test through Memory Gateway",
"created_at": "2026-06-12T01:55:41.527716+00:00",
"updated_at": "2026-06-12T01:55:46.204082+00:00"
}
]
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001634
```
## 10. Search resource memory
Request:
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"query": "图片中有哪些颜色和形状?",
curl -sS -X POST http://127.0.0.1:8010/memories/search \
-H 'Content-Type: application/json' \
-d '{
"user_id": "gateway_demo_user",
"user_key": "<USER_KEY>",
"query": "这张资源图片里有哪些几何图形和颜色?",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
@ -456,251 +283,43 @@ curl -sS --location 'http://127.0.0.1:8010/memories/search' \
}'
```
Response:
Expected result excerpt:
```json
{
"results": [
{
"id": "other_api_20260612095541_ep_20260612_00000001",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"text": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a plain, light gray background. The composition included a solid red square in the upper-left portion, a solid blue circle in the upper-right portion, and a long, thin, horizontal green rectangle situated below both shapes. The red square and blue circle were roughly aligned horizontally, while the green rectangle spanned a width greater than either of the upper shapes.",
"score": 0.6418947577476501,
"source_scope": "resources",
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"resource_uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"raw": {
"id": "other_api_20260612095541_ep_20260612_00000001",
"user_id": "other_api_20260612095541",
"app_id": "default",
"project_id": "default",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"timestamp": "2026-06-12T01:55:41.541000Z",
"sender_ids": ["other_api_20260612095541"],
"summary": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a",
"subject": "Visual Analysis of Geometric Shapes Uploaded by other_api_20260612095541 on June 12, 2026",
"episode": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a plain, light gray background. The composition included a solid red square in the upper-left portion, a solid blue circle in the upper-right portion, and a long, thin, horizontal green rectangle situated below both shapes. The red square and blue circle were roughly aligned horizontally, while the green rectangle spanned a width greater than either of the upper shapes.",
"type": "Conversation",
"score": 0.6418947577476501,
"atomic_facts": [
{
"id": "other_api_20260612095541_af_20260612_00000001",
"content": "The image displays three distinct geometric shapes on a plain, light gray background.",
"score": 0.6418947577476501
}
]
}
"resource_id": "r_1678eacf3e8c49f9a8863454c5b35e68",
"resource_uri": "resource://gateway_demo_user/r_1678eacf3e8c49f9a8863454c5b35e68",
"text": "The image displayed a red square, a blue circle, and a green rectangle.",
"attachments": [
{
"type": "image",
"name": "simple-multimodal-image.png",
"internal_uri": "file:///home/tom/memory-gateway/data/storage/..."
}
]
}
]
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.176981
```
## Multipart vs resources
## 11. Override memory
Use `/memories/add/multipart` when the upload belongs to a chat/session message:
Request:
- caller supplies `session_id`, usually `chat:{conversation_id}`;
- caller defines `upload_id` values in `messages`;
- caller uploads files as form fields with names matching `upload_id`;
- Gateway only calls upstream add;
- caller should call `/memories/flush`;
- search normally uses `current_chat` or `all_user_memory`.
```bash
curl -sS --location --request PATCH \
'http://127.0.0.1:8010/memories/other_api_20260612095541_ep_20260612_00000001' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"override_text": "OVERRIDE: 图片左侧是红色方块,右侧是蓝色圆形,底部是绿色横条。"
}'
```
Use `/resources` when the upload is an independent resource:
Response:
```json
{
"memory_id": "other_api_20260612095541_ep_20260612_00000001",
"override_id": "o_328f03b40b164c4896640fd2567042cb",
"status": "active"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.007037
```
The next search returned the overridden text:
Request:
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"query": "图片中有哪些颜色和形状?",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
```json
{
"results": [
{
"id": "other_api_20260612095541_ep_20260612_00000001",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"text": "OVERRIDE: 图片左侧是红色方块,右侧是蓝色圆形,底部是绿色横条。",
"score": 0.6418947577476501,
"source_scope": "resources",
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"resource_uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"raw": {
"id": "other_api_20260612095541_ep_20260612_00000001",
"user_id": "other_api_20260612095541",
"app_id": "default",
"project_id": "default",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"timestamp": "2026-06-12T01:55:41.541000Z",
"sender_ids": ["other_api_20260612095541"],
"summary": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a",
"subject": "Visual Analysis of Geometric Shapes Uploaded by other_api_20260612095541 on June 12, 2026",
"episode": "On June 12, 2026 at 01:55 AM UTC, the user other_api_20260612095541 uploaded an image titled 'simple-multimodal-image.png' for visual analysis. The image displayed three distinct geometric shapes on a plain, light gray background. The composition included a solid red square in the upper-left portion, a solid blue circle in the upper-right portion, and a long, thin, horizontal green rectangle situated below both shapes. The red square and blue circle were roughly aligned horizontally, while the green rectangle spanned a width greater than either of the upper shapes.",
"type": "Conversation",
"score": 0.6418947577476501,
"atomic_facts": [
{
"id": "other_api_20260612095541_af_20260612_00000001",
"content": "The image displays three distinct geometric shapes on a plain, light gray background.",
"score": 0.6418947577476501
}
]
},
"override_id": "o_328f03b40b164c4896640fd2567042cb"
}
]
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.055485
```
## 12. Delete memory with tombstone
Request:
```bash
curl -sS --location --request DELETE \
'http://127.0.0.1:8010/memories/other_api_20260612095541_ep_20260612_00000001' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"reason": "Gateway API tombstone test"
}'
```
Response:
```json
{
"memory_id": "other_api_20260612095541_ep_20260612_00000001",
"tombstone_id": "t_2cba49bf3b6641ea96865612deebc036",
"status": "deleted"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.006502
```
Repeating the resource search after creating the tombstone:
```bash
curl -sS --location 'http://127.0.0.1:8010/memories/search' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "other_api_20260612095541",
"user_key": "uk_REDACTED",
"query": "图片中有哪些颜色和形状?",
"scope": ["resources"],
"top_k": 5,
"app_id": "default",
"project_id": "default"
}'
```
```json
{"results":[]}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.067841
```
## 13. Delete resource
Request:
```bash
curl -sS --location --request DELETE \
'http://127.0.0.1:8010/resources/r_2700e435f72a49e6a7f736d17f8c7ac7?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
Response:
```json
{
"resource_id": "r_2700e435f72a49e6a7f736d17f8c7ac7",
"session_id": "resource:other_api_20260612095541:r_2700e435f72a49e6a7f736d17f8c7ac7",
"uri": "resource://other_api_20260612095541/r_2700e435f72a49e6a7f736d17f8c7ac7",
"status": "deleted"
}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.014089
```
List after deletion:
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
```json
{"resources":[]}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001226
```
Detail after deletion:
```bash
curl -sS --location \
'http://127.0.0.1:8010/resources/r_2700e435f72a49e6a7f736d17f8c7ac7?user_id=other_api_20260612095541&user_key=uk_REDACTED'
```
```json
{"resources":[]}
```
```text
HTTP_STATUS:200
TOTAL_TIME:0.001223
```
- Gateway creates `resource_id`;
- Gateway creates `session_id = resource:{user_id}:{resource_id}`;
- Gateway writes `user_resources`;
- Gateway automatically calls upstream add and flush;
- search normally uses `resources`.

View File

@ -897,6 +897,117 @@ async def test_add_memory_materializes_base64_attachment(
assert backend.add_calls[0]["messages"][0]["content"][0]["base64"] == encoded
@pytest.mark.asyncio
async def test_add_memory_multipart_uploads_files_to_chat_session(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
backend = FakeBackendClient()
messages = [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{"type": "text", "text": "remember these attachments"},
{
"type": "image",
"upload_id": "image_1",
"name": "picture.png",
"ext": "png",
},
{
"type": "audio",
"upload_id": "audio_1",
"name": "tone.wav",
"ext": "wav",
},
],
}
]
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add/multipart",
data={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_uploads",
"app_id": "default",
"project_id": "default",
"messages": json.dumps(messages),
},
files=[
("image_1", ("picture.png", b"png bytes", "image/png")),
("audio_1", ("tone.wav", b"wav bytes", "audio/wav")),
],
)
assert response.status_code == 200, response.text
assert response.json() == {
"session_id": "chat:c_uploads",
"backend": {"request_id": "add", "data": {"status": "accumulated"}},
}
content = backend.add_calls[0]["messages"][0]["content"]
assert content == [
{"type": "text", "text": "remember these attachments"},
{
"type": "image",
"name": "picture.png",
"ext": "png",
"base64": base64.b64encode(b"png bytes").decode("ascii"),
},
{
"type": "audio",
"name": "tone.wav",
"ext": "wav",
"base64": base64.b64encode(b"wav bytes").decode("ascii"),
},
]
attachments = repo.list_attachments_for_session("u_123", "chat:c_uploads")
assert [(item["name"], item["source"]) for item in attachments] == [
("picture.png", "memory_add_upload"),
("tone.wav", "memory_add_upload"),
]
for attachment in attachments:
path = Path(attachment["internal_uri"].removeprefix("file://"))
assert path.exists()
@pytest.mark.asyncio
async def test_add_memory_multipart_rejects_missing_upload_file(
config: GatewayConfig,
) -> None:
backend = FakeBackendClient()
messages = [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": [
{"type": "image", "upload_id": "image_1", "name": "picture.png"}
],
}
]
async with app_client(config, backend) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add/multipart",
data={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_missing_upload",
"messages": json.dumps(messages),
},
)
assert response.status_code == 422
assert "missing upload file for upload_id: image_1" in response.text
assert backend.add_calls == []
@pytest.mark.asyncio
async def test_add_memory_deduplicates_retried_base64_attachment(
config: GatewayConfig,