Add resource upload APIs

This commit is contained in:
2026-05-29 11:47:51 +08:00
parent c173fa45a7
commit 0ab2a35e16
8 changed files with 514 additions and 18 deletions

View File

@ -152,6 +152,8 @@ API=http://127.0.0.1:1934/memory-system
| `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 | | `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 |
| `GET/POST` | `/sessions/{session_id}/context` | 查询 OpenViking 会话上下文,并用同一 query 搜索 EverOS 记忆 | 需要 | | `GET/POST` | `/sessions/{session_id}/context` | 查询 OpenViking 会话上下文,并用同一 query 搜索 EverOS 记忆 | 需要 |
| `GET/POST` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 | | `GET/POST` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 |
| `POST` | `/resources` | 上传本地文件或远程 URL 到 OpenViking resources | 需要 |
| `DELETE` | `/resources` | 删除 OpenViking resource URI | 需要 |
| `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 | | `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 |
| `GET/POST` | `/users/{user_id}/profile` | 查询 EverOS profile并补充 OpenViking 用户记忆搜索结果 | 需要 | | `GET/POST` | `/users/{user_id}/profile` | 查询 EverOS profile并补充 OpenViking 用户记忆搜索结果 | 需要 |
@ -355,6 +357,90 @@ curl -sS -X POST "$API/openviking/tasks/${TASK_ID}" \
}' }'
``` ```
### `POST /resources`
上传文件资源到 OpenViking。网关只调用 OpenViking不写 EverOS。
如果 `path` 是本地路径,文件必须能被 Memory System API 服务进程读取。网关会先调用 OpenViking `/api/v1/resources/temp_upload` 上传临时文件,取返回的 `temp_file_id`,再调用 `/api/v1/resources` 添加资源。
如果 `path``http://``https://` URL网关会直接调用 OpenViking `/api/v1/resources`,并把 URL 作为 `path` 传给 OpenViking。
请求体:
| 参数 | 类型 | 必需 | 说明 |
|---|---|---:|---|
| `user_id` | string | 是 | 用户 ID |
| `user_key` | string | 是 | `/users` 返回的 user key |
| `path` | string | 是 | 本地文件路径,或 `http://` / `https://` URL |
| `to` | string | 是 | 目标 OpenViking resource URI |
| `reason` | string/null | 否 | 上传原因,透传给 OpenViking |
| `wait` | bool | 否 | 是否等待处理完成,默认 `true` |
| `directly_upload_media` | bool | 否 | 是否直接上传媒体,默认 `true` |
本地文件示例:
```bash
curl -sS -X POST "$API/resources" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userA",
"user_key": "'"$USER_KEY"'",
"path": "/home/tom/memory-gateway/tests/大语言模型应用.pdf",
"to": "viking://resources/userA/files/大语言模型应用.pdf",
"reason": "userA 上传的文件",
"wait": true,
"directly_upload_media": true
}'
```
远程 URL 示例:
```bash
curl -sS -X POST "$API/resources" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userA",
"user_key": "'"$USER_KEY"'",
"path": "https://example.com/images/photo.png",
"to": "viking://resources/userA/images/photo.png",
"reason": "userA 上传的远程图片",
"wait": true,
"directly_upload_media": true
}'
```
返回中的 `resource` 是 OpenViking `/api/v1/resources` 的原始响应,`backends.openviking` 保留调用状态和错误信息。
### `DELETE /resources`
删除 OpenViking resource URI。网关会调用 OpenViking
```http
DELETE /api/v1/fs?uri=<OPENVIKING_URI>&recursive=<true|false>
X-API-Key: <user_key>
```
Query 参数:
| 参数 | 类型 | 必需 | 说明 |
|---|---|---:|---|
| `user_id` | string | 是 | 用户 ID |
| `user_key` | string | 是 | `/users` 返回的 user key |
| `uri` | string | 是 | 要删除的 OpenViking URI |
| `recursive` | bool | 否 | 是否递归删除,默认 `true` |
示例:
```bash
curl -sS -X DELETE -G "$API/resources" \
--data-urlencode "user_id=userA" \
--data-urlencode "user_key=$USER_KEY" \
--data-urlencode "uri=viking://resources/userA/files/大语言模型应用.pdf" \
--data-urlencode "recursive=true"
```
返回中的 `resource` 是 OpenViking `/api/v1/fs` 删除接口的原始响应。
### `POST /search` ### `POST /search`
同时查询 OpenViking 和 EverOS并合并结果。 同时查询 OpenViking 和 EverOS并合并结果。

View File

@ -7,6 +7,7 @@ from .auth import verify_api_key
from .schemas import ( from .schemas import (
MessageIngestRequest, MessageIngestRequest,
ProfileRequest, ProfileRequest,
ResourceUploadRequest,
SearchRequest, SearchRequest,
SessionContextRequest, SessionContextRequest,
SessionUserRequest, SessionUserRequest,
@ -54,6 +55,31 @@ async def ingest_messages(
raise user_auth_error(exc) from exc raise user_auth_error(exc) from exc
@router.post("/resources")
async def upload_resource(
request: ResourceUploadRequest,
service: MemorySystemService = Depends(get_service),
):
try:
return await service.upload_resource(request)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.delete("/resources")
async def delete_resource(
user_id: str = Query(min_length=1),
user_key: str = Query(min_length=1),
uri: str = Query(min_length=1),
recursive: bool = Query(default=True),
service: MemorySystemService = Depends(get_service),
):
try:
return await service.delete_resource(user_id, user_key, uri, recursive=recursive)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.post("/sessions/{session_id}/commit") @router.post("/sessions/{session_id}/commit")
async def commit_session( async def commit_session(
session_id: str, session_id: str,

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
@ -136,6 +137,59 @@ class OpenVikingMemorySystemClient:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def upload_temp_file(self, credential: OpenVikingCredential | str, path: str | Path) -> dict[str, Any]:
file_path = Path(path)
async with self._credential_client(credential, json_content_type=False) as client:
with file_path.open("rb") as file_obj:
response = await client.post(
"/api/v1/resources/temp_upload",
files={"file": (file_path.name, file_obj)},
)
response.raise_for_status()
return response.json()
async def add_resource(
self,
credential: OpenVikingCredential | str,
*,
to: str,
reason: str | None = None,
wait: bool = True,
directly_upload_media: bool = True,
path: str | None = None,
temp_file_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"to": to,
"wait": wait,
"directly_upload_media": directly_upload_media,
}
if reason is not None:
payload["reason"] = reason
if temp_file_id is not None:
payload["temp_file_id"] = temp_file_id
else:
payload["path"] = path
async with self._credential_client(credential) as client:
response = await client.post("/api/v1/resources", json=payload)
response.raise_for_status()
return response.json()
async def delete_resource(
self,
credential: OpenVikingCredential | str,
uri: str,
recursive: bool = True,
) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.delete(
"/api/v1/fs",
params={"uri": uri, "recursive": str(recursive).lower()},
)
response.raise_for_status()
return response.json()
async def find(self, credential: OpenVikingCredential | str, query: str, limit: int) -> dict[str, Any]: async def find(self, credential: OpenVikingCredential | str, query: str, limit: int) -> dict[str, Any]:
user_id = credential.user_id if isinstance(credential, OpenVikingCredential) else None user_id = credential.user_id if isinstance(credential, OpenVikingCredential) else None
target_uri = f"viking://user/{user_id}/memories/" if user_id else "viking://user/memories/" target_uri = f"viking://user/{user_id}/memories/" if user_id else "viking://user/memories/"
@ -198,11 +252,15 @@ class OpenVikingMemorySystemClient:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def _credential_client(self, credential: OpenVikingCredential | str) -> httpx.AsyncClient: def _credential_client(
self,
credential: OpenVikingCredential | str,
json_content_type: bool = True,
) -> httpx.AsyncClient:
if isinstance(credential, str): if isinstance(credential, str):
return self._client(credential) return self._client(credential, json_content_type=json_content_type)
if credential.user_key_auth: if credential.user_key_auth:
return self._client(credential.api_key) return self._client(credential.api_key, json_content_type=json_content_type)
headers = {} headers = {}
if credential.account_id: if credential.account_id:
headers["X-OpenViking-Account"] = credential.account_id headers["X-OpenViking-Account"] = credential.account_id
@ -210,10 +268,17 @@ class OpenVikingMemorySystemClient:
headers["X-OpenViking-User"] = credential.user_id headers["X-OpenViking-User"] = credential.user_id
if credential.agent_id: if credential.agent_id:
headers["X-OpenViking-Agent"] = credential.agent_id headers["X-OpenViking-Agent"] = credential.agent_id
return self._client(credential.api_key, headers) return self._client(credential.api_key, headers, json_content_type=json_content_type)
def _client(self, api_key: str, extra_headers: dict[str, str] | None = None) -> httpx.AsyncClient: def _client(
headers = {"Content-Type": "application/json", "X-API-Key": api_key} self,
api_key: str,
extra_headers: dict[str, str] | None = None,
json_content_type: bool = True,
) -> httpx.AsyncClient:
headers = {"X-API-Key": api_key}
if json_content_type:
headers["Content-Type"] = "application/json"
if extra_headers: if extra_headers:
headers.update(extra_headers) headers.update(extra_headers)
return httpx.AsyncClient( return httpx.AsyncClient(

View File

@ -54,6 +54,16 @@ class ProfileRequest(BaseModel):
level: int = Field(default=2, ge=0) level: int = Field(default=2, ge=0)
class ResourceUploadRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
path: str = Field(min_length=1)
to: str = Field(min_length=1)
reason: str | None = None
wait: bool = True
directly_upload_media: bool = True
class BackendStatus(BaseModel): class BackendStatus(BaseModel):
status: OperationStatus status: OperationStatus
result: Any = None result: Any = None
@ -104,3 +114,9 @@ class ProfileResponse(BaseModel):
profile: Any = None profile: Any = None
items: list[dict[str, Any]] = Field(default_factory=list) items: list[dict[str, Any]] = Field(default_factory=list)
backends: dict[str, BackendStatus] backends: dict[str, BackendStatus]
class ResourceMutationResponse(BaseModel):
status: OperationStatus
resource: Any = None
backends: dict[str, BackendStatus]

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from urllib.parse import urlparse
from .clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient from .clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient
from .schemas import ( from .schemas import (
@ -13,6 +14,8 @@ from .schemas import (
MessageIngestRequest, MessageIngestRequest,
MessageIngestResponse, MessageIngestResponse,
ProfileResponse, ProfileResponse,
ResourceMutationResponse,
ResourceUploadRequest,
SearchRequest, SearchRequest,
SearchResponse, SearchResponse,
SessionContextRequest, SessionContextRequest,
@ -30,6 +33,49 @@ class MemorySystemService:
account = backends["openviking"].result if backends["openviking"].status == "success" else None account = backends["openviking"].result if backends["openviking"].status == "success" else None
return AccountResponse(status=self._aggregate_status(backends), account=account, backends=backends) return AccountResponse(status=self._aggregate_status(backends), account=account, backends=backends)
async def upload_resource(self, request: ResourceUploadRequest) -> ResourceMutationResponse:
credential = self.openviking.credential_for_user(request.user_id, request.user_key)
async def upload_openviking() -> dict[str, Any]:
if self._is_remote_url(request.path):
return await self.openviking.add_resource(
credential,
path=request.path,
to=request.to,
reason=request.reason,
wait=request.wait,
directly_upload_media=request.directly_upload_media,
)
temp_upload = await self.openviking.upload_temp_file(credential, request.path)
temp_file_id = self._temp_file_id_from_result(temp_upload)
return await self.openviking.add_resource(
credential,
temp_file_id=temp_file_id,
to=request.to,
reason=request.reason,
wait=request.wait,
directly_upload_media=request.directly_upload_media,
)
backends = {"openviking": await self._capture(upload_openviking)}
resource = backends["openviking"].result if backends["openviking"].status == "success" else None
return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends)
async def delete_resource(
self,
user_id: str,
user_key: str,
uri: str,
recursive: bool = True,
) -> ResourceMutationResponse:
credential = self.openviking.credential_for_user(user_id, user_key)
backends = {
"openviking": await self._capture(lambda: self.openviking.delete_resource(credential, uri, recursive)),
}
resource = backends["openviking"].result if backends["openviking"].status == "success" else None
return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends)
async def ingest_messages(self, request: MessageIngestRequest) -> MessageIngestResponse: async def ingest_messages(self, request: MessageIngestRequest) -> MessageIngestResponse:
messages = self._messages_from_request(request) messages = self._messages_from_request(request)
if not messages: if not messages:
@ -204,6 +250,16 @@ class MemorySystemService:
messages.append({"role": "assistant", "content": request.assistant_message}) messages.append({"role": "assistant", "content": request.assistant_message})
return messages return messages
def _is_remote_url(self, path: str) -> bool:
return urlparse(path).scheme in {"http", "https"}
def _temp_file_id_from_result(self, result: Any) -> str:
data = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
temp_file_id = data.get("temp_file_id") if isinstance(data, dict) else None
if not temp_file_id:
raise ValueError("OpenViking temp upload response missing temp_file_id")
return str(temp_file_id)
async def _run_backends(self, **calls: Callable[[], Awaitable[Any]]) -> dict[str, BackendStatus]: async def _run_backends(self, **calls: Callable[[], Awaitable[Any]]) -> dict[str, BackendStatus]:
names = list(calls) names = list(calls)
results = await asyncio.gather(*(self._capture(calls[name]) for name in names)) results = await asyncio.gather(*(self._capture(calls[name]) for name in names))

View File

@ -61,14 +61,21 @@ class FakeAsyncClient:
async def __aexit__(self, exc_type, exc, tb): async def __aexit__(self, exc_type, exc, tb):
return False return False
async def post(self, path: str, json: dict | None = None) -> FakeResponse: async def post(self, path: str, json: dict | None = None, files: dict | None = None) -> FakeResponse:
self.calls.append(("post", self.api_key, self.headers, path, json)) if files and "file" in files:
uploaded = files["file"]
files = {"file": uploaded[0] if isinstance(uploaded, tuple) else uploaded}
self.calls.append(("post", self.api_key, self.headers, path, json, files))
return self.responses.pop(0) return self.responses.pop(0)
async def get(self, path: str) -> FakeResponse: async def get(self, path: str) -> FakeResponse:
self.calls.append(("get", self.api_key, self.headers, path, None)) self.calls.append(("get", self.api_key, self.headers, path, None))
return self.responses.pop(0) return self.responses.pop(0)
async def delete(self, path: str, params: dict | None = None) -> FakeResponse:
self.calls.append(("delete", self.api_key, self.headers, path, params))
return self.responses.pop(0)
def test_openviking_rejects_unknown_user_credentials(): def test_openviking_rejects_unknown_user_credentials():
store = FakeStore() store = FakeStore()
@ -126,7 +133,7 @@ def test_openviking_create_user_creates_isolated_admin_account():
}, },
), ),
] ]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -152,6 +159,7 @@ def test_openviking_create_user_creates_isolated_admin_account():
{}, {},
"/api/v1/admin/accounts", "/api/v1/admin/accounts",
{"account_id": "userA_account", "admin_user_id": "userA"}, {"account_id": "userA_account", "admin_user_id": "userA"},
None,
), ),
] ]
@ -175,7 +183,7 @@ def test_openviking_create_user_creates_account_even_when_admin_workspace_exists
}, },
) )
] ]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -201,6 +209,7 @@ def test_openviking_create_user_creates_account_even_when_admin_workspace_exists
{}, {},
"/api/v1/admin/accounts", "/api/v1/admin/accounts",
{"account_id": "userB_account", "admin_user_id": "userB"}, {"account_id": "userB_account", "admin_user_id": "userB"},
None,
) )
] ]
@ -210,7 +219,7 @@ def test_openviking_user_key_auth_is_used_for_session_create():
client.root_key = "root-key" client.root_key = "root-key"
calls = [] calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"session_id": "sess-2"}})] responses = [FakeResponse(200, {"status": "ok", "result": {"session_id": "sess-2"}})]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -229,6 +238,7 @@ def test_openviking_user_key_auth_is_used_for_session_create():
{}, {},
"/api/v1/sessions", "/api/v1/sessions",
{"session_id": "sess-2"}, {"session_id": "sess-2"},
None,
) )
] ]
@ -237,7 +247,7 @@ def test_openviking_find_uses_current_identity_memory_scope():
client = OpenVikingMemorySystemClient(store=FakeStore()) client = OpenVikingMemorySystemClient(store=FakeStore())
calls = [] calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -255,6 +265,7 @@ def test_openviking_find_uses_current_identity_memory_scope():
{}, {},
"/api/v1/search/find", "/api/v1/search/find",
{"query": "咖啡", "target_uri": "viking://user/tom/memories/", "limit": 5}, {"query": "咖啡", "target_uri": "viking://user/tom/memories/", "limit": 5},
None,
) )
] ]
@ -263,7 +274,7 @@ def test_openviking_search_uses_fixed_user_memory_target_with_level_and_score_th
client = OpenVikingMemorySystemClient(store=FakeStore()) client = OpenVikingMemorySystemClient(store=FakeStore())
calls = [] calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -287,6 +298,7 @@ def test_openviking_search_uses_fixed_user_memory_target_with_level_and_score_th
"level": 3, "level": 3,
"score_threshold": 0.7, "score_threshold": 0.7,
}, },
None,
) )
] ]
@ -295,7 +307,7 @@ def test_openviking_search_accepts_custom_target_uri():
client = OpenVikingMemorySystemClient(store=FakeStore()) client = OpenVikingMemorySystemClient(store=FakeStore())
calls = [] calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -326,6 +338,7 @@ def test_openviking_search_accepts_custom_target_uri():
"level": 3, "level": 3,
"score_threshold": 0.7, "score_threshold": 0.7,
}, },
None,
) )
] ]
@ -334,7 +347,7 @@ def test_openviking_profile_search_uses_user_memory_target_and_level():
client = OpenVikingMemorySystemClient(store=FakeStore()) client = OpenVikingMemorySystemClient(store=FakeStore())
calls = [] calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -357,6 +370,7 @@ def test_openviking_profile_search_uses_user_memory_target_and_level():
"level": 2, "level": 2,
"target_uri": "viking://user/memories", "target_uri": "viking://user/memories",
}, },
None,
) )
] ]
@ -376,7 +390,7 @@ def test_openviking_get_session_context_uses_user_key_auth():
}, },
) )
] ]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -420,7 +434,7 @@ def test_openviking_commit_keeps_no_recent_live_messages():
}, },
) )
] ]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls, calls,
responses, responses,
api_key, api_key,
@ -446,6 +460,115 @@ def test_openviking_commit_keeps_no_recent_live_messages():
{}, {},
"/api/v1/sessions/sess-1/commit", "/api/v1/sessions/sess-1/commit",
{"keep_recent_count": 0}, {"keep_recent_count": 0},
None,
)
]
def test_openviking_upload_temp_file_posts_multipart(tmp_path):
path = tmp_path / "report.pdf"
path.write_bytes(b"pdf-bytes")
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [
FakeResponse(
200,
{"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}},
)
]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(client.upload_temp_file(credential, path))
assert result == {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/resources/temp_upload",
None,
{"file": "report.pdf"},
)
]
def test_openviking_add_resource_posts_url_payload():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.add_resource(
credential,
path="https://example.com/photo.png",
to="viking://resources/tom/images/photo.png",
reason="上传远程图片",
wait=True,
directly_upload_media=True,
)
)
assert result == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}}
assert calls == [
(
"post",
"tom-key",
{},
"/api/v1/resources",
{
"path": "https://example.com/photo.png",
"to": "viking://resources/tom/images/photo.png",
"reason": "上传远程图片",
"wait": True,
"directly_upload_media": True,
},
None,
)
]
def test_openviking_delete_resource_sends_uri_and_recursive_flag():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [FakeResponse(200, {"status": "ok", "result": {"estimated_deleted_count": 4}})]
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom")
result = asyncio.run(
client.delete_resource(
credential,
uri="viking://resources/tom/files/report.pdf",
recursive=True,
)
)
assert result == {"status": "ok", "result": {"estimated_deleted_count": 4}}
assert calls == [
(
"delete",
"tom-key",
{},
"/api/v1/fs",
{"uri": "viking://resources/tom/files/report.pdf", "recursive": "true"},
) )
] ]

View File

@ -13,6 +13,7 @@ def test_memory_system_server_exposes_routes():
} }
assert {"GET", "POST"} <= context_methods assert {"GET", "POST"} <= context_methods
assert "/memory-system/search" in paths assert "/memory-system/search" in paths
assert "/memory-system/resources" in paths
assert "/memory-system/users/{user_id}/profile" in paths assert "/memory-system/users/{user_id}/profile" in paths
task_methods = { task_methods = {
method method
@ -28,6 +29,13 @@ def test_memory_system_server_exposes_routes():
} }
assert {"GET", "POST"} <= task_methods assert {"GET", "POST"} <= task_methods
assert {"GET", "POST"} <= profile_methods assert {"GET", "POST"} <= profile_methods
resource_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/resources"
for method in getattr(route, "methods", set())
}
assert {"DELETE", "POST"} <= resource_methods
def test_memory_system_messages_does_not_require_account_key_header(): def test_memory_system_messages_does_not_require_account_key_header():

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
from memory_system_api.schemas import MessageIngestRequest, SearchRequest, SessionContextRequest from memory_system_api.schemas import MessageIngestRequest, ResourceUploadRequest, SearchRequest, SessionContextRequest
from memory_system_api.service import MemorySystemService from memory_system_api.service import MemorySystemService
@ -89,6 +89,37 @@ class FakeOpenViking:
self.calls.append(("commit_session", user_key, session_id)) self.calls.append(("commit_session", user_key, session_id))
return {"status": "ok", "result": {"task_id": "task-1", "archive_uri": "archive-1"}} return {"status": "ok", "result": {"task_id": "task-1", "archive_uri": "archive-1"}}
async def upload_temp_file(self, user_key: str, path) -> dict:
self.calls.append(("upload_temp_file", user_key, str(path)))
return {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}
async def add_resource(
self,
user_key: str,
*,
to: str,
reason: str | None,
wait: bool,
directly_upload_media: bool,
path: str | None = None,
temp_file_id: str | None = None,
) -> dict:
self.calls.append((
"add_resource",
user_key,
path,
temp_file_id,
to,
reason,
wait,
directly_upload_media,
))
return {"status": "ok", "result": {"uri": to}}
async def delete_resource(self, user_key: str, uri: str, recursive: bool = True) -> dict:
self.calls.append(("delete_resource", user_key, uri, recursive))
return {"status": "ok", "result": {"uri": uri, "estimated_deleted_count": 4}}
class FakeEverOS: class FakeEverOS:
def __init__(self, fail_on_append: bool = False): def __init__(self, fail_on_append: bool = False):
@ -218,6 +249,91 @@ def test_create_user_delegates_to_openviking_only():
assert everos.calls == [] assert everos.calls == []
def test_upload_resource_with_url_delegates_directly_to_openviking_add_resource():
openviking = FakeOpenViking()
service = MemorySystemService(openviking=openviking, everos=FakeEverOS())
response = asyncio.run(service.upload_resource(ResourceUploadRequest(
user_id="tom",
user_key="tom-key",
path="https://example.com/images/photo.png",
to="viking://resources/tom/images/photo.png",
reason="上传远程图片",
)))
assert response.status == "success"
assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
(
"add_resource",
"key-tom",
"https://example.com/images/photo.png",
None,
"viking://resources/tom/images/photo.png",
"上传远程图片",
True,
True,
),
]
def test_upload_resource_with_local_path_uploads_temp_file_first(tmp_path):
path = tmp_path / "report.pdf"
path.write_bytes(b"pdf-bytes")
openviking = FakeOpenViking()
service = MemorySystemService(openviking=openviking, everos=FakeEverOS())
response = asyncio.run(service.upload_resource(ResourceUploadRequest(
user_id="tom",
user_key="tom-key",
path=str(path),
to="viking://resources/tom/files/report.pdf",
reason="上传本地文件",
)))
assert response.status == "success"
assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/files/report.pdf"}}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("upload_temp_file", "key-tom", str(path)),
(
"add_resource",
"key-tom",
None,
"upload_report.pdf",
"viking://resources/tom/files/report.pdf",
"上传本地文件",
True,
True,
),
]
def test_delete_resource_delegates_to_openviking_only():
openviking = FakeOpenViking()
everos = FakeEverOS()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(service.delete_resource(
user_id="tom",
user_key="tom-key",
uri="viking://resources/tom/files/report.pdf",
recursive=True,
))
assert response.status == "success"
assert response.resource == {
"status": "ok",
"result": {"uri": "viking://resources/tom/files/report.pdf", "estimated_deleted_count": 4},
}
assert openviking.calls == [
("credential_for_user", "tom", "tom-key", None),
("delete_resource", "key-tom", "viking://resources/tom/files/report.pdf", True),
]
assert everos.calls == []
def test_search_removes_vectors_from_items_and_backend_results(): def test_search_removes_vectors_from_items_and_backend_results():
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSWithVector()) service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSWithVector())