diff --git a/.github/workflows/python-client.yml b/.github/workflows/python-client.yml
new file mode 100644
index 00000000..32e2e0a9
--- /dev/null
+++ b/.github/workflows/python-client.yml
@@ -0,0 +1,70 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Python Client
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'memind-clients/python/**'
+ - '.github/workflows/python-client.yml'
+ - '.github/workflows/release-python-client.yml'
+ pull_request:
+ paths:
+ - 'memind-clients/python/**'
+ - '.github/workflows/python-client.yml'
+ - '.github/workflows/release-python-client.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.10', '3.11', '3.12', '3.13']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: memind-clients/python/pyproject.toml
+
+ - name: Install
+ working-directory: memind-clients/python
+ run: pip install -e ".[dev]"
+
+ - name: Ruff format
+ working-directory: memind-clients/python
+ run: ruff format --check .
+
+ - name: Ruff check
+ working-directory: memind-clients/python
+ run: ruff check .
+
+ - name: Mypy
+ working-directory: memind-clients/python
+ run: mypy src
+
+ - name: Pytest
+ working-directory: memind-clients/python
+ run: pytest --cov=memind --cov-fail-under=90 -q
diff --git a/.github/workflows/release-python-client.yml b/.github/workflows/release-python-client.yml
new file mode 100644
index 00000000..21f7c6ff
--- /dev/null
+++ b/.github/workflows/release-python-client.yml
@@ -0,0 +1,89 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: Release Python Client to PyPI
+
+# Release prerequisites:
+# - The `memind` project must exist on PyPI and be owned by OpenMemind maintainers.
+# - PyPI Trusted Publishing must trust this workflow path and the `pypi` environment.
+# - The GitHub repository must define an environment named `pypi`.
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Python client release version, for example 0.2.0
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ release:
+ name: Build and publish Python client
+ runs-on: ubuntu-latest
+ if: github.ref == 'refs/heads/main'
+ environment: pypi
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ cache: pip
+ cache-dependency-path: memind-clients/python/pyproject.toml
+
+ - name: Install build tools
+ working-directory: memind-clients/python
+ run: pip install -e ".[dev]"
+
+ - name: Validate release version
+ working-directory: memind-clients/python
+ shell: bash
+ run: |
+ version="${{ inputs.version }}"
+ if [[ -z "${version}" || "${version}" == *"-SNAPSHOT" ]]; then
+ echo "Release version must be non-empty and must not end with -SNAPSHOT." >&2
+ exit 1
+ fi
+
+ package_version="$(python -c 'from memind import __version__; print(__version__)')"
+ if [[ "${package_version}" != "${version}" ]]; then
+ echo "Workflow version ${version} does not match package version ${package_version}." >&2
+ exit 1
+ fi
+
+ - name: Verify
+ working-directory: memind-clients/python
+ run: |
+ ruff format --check .
+ ruff check .
+ mypy src
+ pytest --cov=memind --cov-fail-under=90 -q
+
+ - name: Build
+ working-directory: memind-clients/python
+ run: python -m build
+
+ - name: Check package metadata
+ working-directory: memind-clients/python
+ run: twine check dist/*
+
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: memind-clients/python/dist
diff --git a/memind-clients/python/.gitignore b/memind-clients/python/.gitignore
new file mode 100644
index 00000000..03be96e5
--- /dev/null
+++ b/memind-clients/python/.gitignore
@@ -0,0 +1,13 @@
+.coverage
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+.uv-cache/
+.venv/
+build/
+dist/
+htmlcov/
+src/*.egg-info/
+uv.lock
+__pycache__/
+*.py[cod]
diff --git a/memind-clients/python/LICENSE b/memind-clients/python/LICENSE
new file mode 100644
index 00000000..a4cd42b9
--- /dev/null
+++ b/memind-clients/python/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2025 OpenMemind
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/memind-clients/python/README.md b/memind-clients/python/README.md
new file mode 100644
index 00000000..fd054189
--- /dev/null
+++ b/memind-clients/python/README.md
@@ -0,0 +1,71 @@
+# Memind Python Client
+
+Official Python client for the Memind memory engine API.
+
+## Installation
+
+```bash
+pip install memind
+```
+
+## Synchronous Usage
+
+```python
+from memind import MemindClient, Message, Strategy
+from memind.types import ConversationContent
+
+with MemindClient(base_url="http://localhost:8080") as client:
+ health = client.health()
+
+ client.memory.extract(
+ user_id="user-1",
+ agent_id="agent-1",
+ raw_content=ConversationContent(messages=[Message.user("I like coffee")]),
+ )
+
+ client.memory.commit(user_id="user-1", agent_id="agent-1")
+
+ result = client.memory.retrieve(
+ user_id="user-1",
+ agent_id="agent-1",
+ query="What does the user like?",
+ strategy=Strategy.SIMPLE,
+ trace=True,
+ )
+```
+
+## Asynchronous Usage
+
+```python
+from memind import AsyncMemindClient, Strategy
+
+async with AsyncMemindClient(base_url="http://localhost:8080") as client:
+ result = await client.memory.retrieve(
+ user_id="user-1",
+ agent_id="agent-1",
+ query="What does the user like?",
+ strategy=Strategy.DEEP,
+ )
+```
+
+## Configuration
+
+Configuration precedence:
+
+1. Constructor arguments
+2. Environment variables: `MEMIND_BASE_URL`, `MEMIND_API_TOKEN`
+3. Defaults: connect timeout `5s`, read timeout `30s`, max retries `2`
+
+`base_url` is required. `api_token` is optional; when omitted no `Authorization` header is sent.
+
+## Development
+
+```bash
+pip install -e ".[dev]"
+ruff format .
+ruff check .
+mypy src
+pytest --cov=memind --cov-fail-under=90 -q
+python -m build
+twine check dist/*
+```
diff --git a/memind-clients/python/pyproject.toml b/memind-clients/python/pyproject.toml
new file mode 100644
index 00000000..82026dd6
--- /dev/null
+++ b/memind-clients/python/pyproject.toml
@@ -0,0 +1,67 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "memind"
+dynamic = ["version"]
+description = "Official Python client for the Memind memory engine API"
+readme = "README.md"
+license = "Apache-2.0"
+requires-python = ">=3.10"
+authors = [
+ { name = "OpenMemind", email = "dev@openmemind.ai" },
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache Software License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Typing :: Typed",
+]
+dependencies = [
+ "httpx>=0.25.0,<1",
+ "pydantic>=2.1.0,<3",
+]
+
+[project.urls]
+Homepage = "https://github.com/openmemind/memind"
+Repository = "https://github.com/openmemind/memind"
+Issues = "https://github.com/openmemind/memind/issues"
+
+[tool.hatch.version]
+path = "src/memind/_version.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/memind"]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0",
+ "pytest-asyncio>=0.23",
+ "pytest-httpx>=0.30",
+ "pytest-cov>=5.0",
+ "ruff",
+ "mypy",
+ "build>=1.2",
+ "twine>=5.0",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"
+
+[tool.ruff]
+target-version = "py310"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP"]
+
+[tool.mypy]
+python_version = "3.10"
+strict = true
diff --git a/memind-clients/python/src/memind/__init__.py b/memind-clients/python/src/memind/__init__.py
new file mode 100644
index 00000000..e3c094b5
--- /dev/null
+++ b/memind-clients/python/src/memind/__init__.py
@@ -0,0 +1,97 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from memind._async_client import AsyncMemindClient
+from memind._client import MemindClient
+from memind._exceptions import (
+ MemindAPIError,
+ MemindAuthenticationError,
+ MemindConnectionError,
+ MemindError,
+ MemindRateLimitError,
+ MemindTimeoutError,
+)
+from memind._version import __version__
+from memind.types import (
+ AddMessageRequest,
+ ApiResult,
+ AudioBlock,
+ Base64Source,
+ CommitMemoryRequest,
+ ContentBlock,
+ ConversationContent,
+ ExtractMemoryRequest,
+ FinalView,
+ HealthResponse,
+ ImageBlock,
+ MapRawContent,
+ MergeView,
+ Message,
+ RawContent,
+ RawContentValue,
+ RetrievalTraceView,
+ RetrievedInsight,
+ RetrievedItem,
+ RetrievedRawData,
+ RetrieveMemoryRequest,
+ RetrieveMemoryResponse,
+ Role,
+ Source,
+ StageView,
+ Strategy,
+ TextBlock,
+ UrlSource,
+ VideoBlock,
+)
+
+__all__ = [
+ "AddMessageRequest",
+ "ApiResult",
+ "AsyncMemindClient",
+ "AudioBlock",
+ "Base64Source",
+ "CommitMemoryRequest",
+ "ContentBlock",
+ "ConversationContent",
+ "ExtractMemoryRequest",
+ "FinalView",
+ "HealthResponse",
+ "ImageBlock",
+ "MapRawContent",
+ "MemindAPIError",
+ "MemindAuthenticationError",
+ "MemindClient",
+ "MemindConnectionError",
+ "MemindError",
+ "MemindRateLimitError",
+ "MemindTimeoutError",
+ "MergeView",
+ "Message",
+ "RawContent",
+ "RawContentValue",
+ "RetrievalTraceView",
+ "RetrieveMemoryRequest",
+ "RetrieveMemoryResponse",
+ "RetrievedInsight",
+ "RetrievedItem",
+ "RetrievedRawData",
+ "Role",
+ "Source",
+ "StageView",
+ "Strategy",
+ "TextBlock",
+ "UrlSource",
+ "VideoBlock",
+ "__version__",
+]
diff --git a/memind-clients/python/src/memind/_async_client.py b/memind-clients/python/src/memind/_async_client.py
new file mode 100644
index 00000000..892639af
--- /dev/null
+++ b/memind-clients/python/src/memind/_async_client.py
@@ -0,0 +1,100 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from types import TracebackType
+from typing import Any, TypeVar
+
+import httpx
+
+from memind._base_client import BaseClient
+from memind._http import RetryConfig, async_request_with_retries
+from memind.resources.async_memory import AsyncMemoryResource
+from memind.types.health import HealthResponse
+
+T = TypeVar("T")
+
+
+class AsyncMemindClient(BaseClient):
+ def __init__(
+ self,
+ *,
+ base_url: str | None = None,
+ api_token: str | None = None,
+ timeout: float | httpx.Timeout | None = None,
+ max_retries: int = 2,
+ http_client: httpx.AsyncClient | None = None,
+ ) -> None:
+ super().__init__(
+ base_url=base_url,
+ api_token=api_token,
+ timeout=timeout,
+ max_retries=max_retries,
+ )
+ self._http_client = http_client or httpx.AsyncClient(timeout=self._timeout)
+ self.memory = AsyncMemoryResource(self)
+
+ async def health(self) -> HealthResponse:
+ result = await self._get("/health", HealthResponse)
+ assert result is not None
+ return result
+
+ async def close(self) -> None:
+ if not self._closed:
+ await self._http_client.aclose()
+ self._mark_closed()
+
+ async def __aenter__(self) -> AsyncMemindClient:
+ self._ensure_open()
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ traceback: TracebackType | None,
+ ) -> None:
+ await self.close()
+
+ async def _get(self, path: str, response_type: type[T] | None) -> T | None:
+ self._ensure_open()
+ response = await async_request_with_retries(
+ self._http_client,
+ "GET",
+ self._build_url(path),
+ retry_config=self._retry_config,
+ headers=self._build_headers(),
+ )
+ return self._process_response(response, response_type)
+
+ async def _post(
+ self,
+ path: str,
+ body: Any,
+ response_type: type[T] | None,
+ *,
+ retry: bool = False,
+ ) -> T | None:
+ self._ensure_open()
+ retry_config = self._retry_config if retry else RetryConfig(max_retries=0)
+ response = await async_request_with_retries(
+ self._http_client,
+ "POST",
+ self._build_url(path),
+ retry_config=retry_config,
+ headers=self._build_headers(),
+ json=self._serialize_body(body),
+ )
+ return self._process_response(response, response_type)
diff --git a/memind-clients/python/src/memind/_base_client.py b/memind-clients/python/src/memind/_base_client.py
new file mode 100644
index 00000000..78bc5445
--- /dev/null
+++ b/memind-clients/python/src/memind/_base_client.py
@@ -0,0 +1,185 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import os
+from typing import Any, TypeVar
+
+import httpx
+from pydantic import TypeAdapter, ValidationError
+
+from memind._constants import (
+ API_PREFIX,
+ DEFAULT_CONNECT_TIMEOUT,
+ DEFAULT_MAX_RETRIES,
+ DEFAULT_READ_TIMEOUT,
+ ENV_API_TOKEN,
+ ENV_BASE_URL,
+)
+from memind._exceptions import (
+ MemindAPIError,
+ MemindAuthenticationError,
+ MemindError,
+ MemindRateLimitError,
+)
+from memind._http import RetryConfig, _retry_after_delay
+from memind._version import __version__
+from memind.types.common import ApiResult
+
+T = TypeVar("T")
+
+
+class BaseClient:
+ def __init__(
+ self,
+ *,
+ base_url: str | None = None,
+ api_token: str | None = None,
+ timeout: float | httpx.Timeout | None = None,
+ max_retries: int = DEFAULT_MAX_RETRIES,
+ ) -> None:
+ resolved_base_url = base_url if base_url is not None else os.getenv(ENV_BASE_URL)
+ if resolved_base_url is None or not resolved_base_url.strip():
+ raise MemindError("base_url is required; pass base_url or set MEMIND_BASE_URL")
+ if max_retries < 0:
+ raise MemindError("max_retries must be non-negative")
+
+ self._base_url = resolved_base_url.strip().rstrip("/")
+ self._api_token = _normalize_token(
+ api_token if api_token is not None else os.getenv(ENV_API_TOKEN)
+ )
+ self._timeout = _normalize_timeout(timeout)
+ self._retry_config = RetryConfig(max_retries=max_retries)
+ self._closed = False
+
+ def _build_url(self, path: str) -> str:
+ normalized_path = path if path.startswith("/") else f"/{path}"
+ return f"{self._base_url}{API_PREFIX}{normalized_path}"
+
+ def _build_headers(self) -> dict[str, str]:
+ headers = {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ "User-Agent": f"memind-python/{__version__}",
+ }
+ if self._api_token is not None:
+ headers["Authorization"] = f"Bearer {self._api_token}"
+ return headers
+
+ def _serialize_body(self, body: Any) -> Any:
+ if hasattr(body, "model_dump"):
+ return body.model_dump(by_alias=True, exclude_none=True)
+ return body
+
+ def _process_response(
+ self, response: httpx.Response, response_type: type[T] | None
+ ) -> T | None:
+ body = _response_json(response)
+ try:
+ result = ApiResult[Any].model_validate(body)
+ except ValidationError as exc:
+ raise MemindAPIError(
+ f"Failed to parse response: {response.text}",
+ status_code=response.status_code,
+ error_code="parse_error",
+ body=body if isinstance(body, dict) else None,
+ ) from exc
+
+ if 200 <= response.status_code < 300 and result.is_success():
+ if response_type is None or result.data is None:
+ return None
+ try:
+ return TypeAdapter(response_type).validate_python(result.data)
+ except ValidationError as exc:
+ raise MemindAPIError(
+ f"Failed to parse response data: {response.text}",
+ status_code=response.status_code,
+ error_code="parse_error",
+ trace_id=result.trace_id,
+ body=result.data if isinstance(result.data, dict) else None,
+ ) from exc
+
+ message = result.message or f"Memind API error: HTTP {response.status_code}"
+ raise self._build_api_error(response, result, body, message)
+
+ def _ensure_open(self) -> None:
+ if self._closed:
+ raise MemindError("Client has been closed")
+
+ def _mark_closed(self) -> None:
+ self._closed = True
+
+ def _build_api_error(
+ self,
+ response: httpx.Response,
+ result: ApiResult[Any],
+ body: Any,
+ message: str,
+ ) -> MemindAPIError:
+ body_dict = body if isinstance(body, dict) else None
+ if response.status_code == 401:
+ return MemindAuthenticationError(
+ message,
+ status_code=response.status_code,
+ error_code=result.code,
+ trace_id=result.trace_id,
+ body=body_dict,
+ )
+ if response.status_code == 429:
+ return MemindRateLimitError(
+ message,
+ status_code=response.status_code,
+ error_code=result.code,
+ trace_id=result.trace_id,
+ body=body_dict,
+ retry_after=_retry_after_seconds(response),
+ )
+ return MemindAPIError(
+ message,
+ status_code=response.status_code,
+ error_code=result.code,
+ trace_id=result.trace_id,
+ body=body_dict,
+ )
+
+
+def _normalize_token(token: str | None) -> str | None:
+ if token is None:
+ return None
+ normalized = token.strip()
+ return normalized or None
+
+
+def _normalize_timeout(timeout: float | httpx.Timeout | None) -> httpx.Timeout:
+ if isinstance(timeout, httpx.Timeout):
+ return timeout
+ if timeout is not None:
+ return httpx.Timeout(timeout)
+ return httpx.Timeout(DEFAULT_READ_TIMEOUT, connect=DEFAULT_CONNECT_TIMEOUT)
+
+
+def _response_json(response: httpx.Response) -> Any:
+ try:
+ return response.json()
+ except ValueError as exc:
+ raise MemindAPIError(
+ f"Failed to parse response: {response.text}",
+ status_code=response.status_code,
+ error_code="parse_error",
+ ) from exc
+
+
+def _retry_after_seconds(response: httpx.Response) -> float | None:
+ return _retry_after_delay(response)
diff --git a/memind-clients/python/src/memind/_client.py b/memind-clients/python/src/memind/_client.py
new file mode 100644
index 00000000..e5c9407a
--- /dev/null
+++ b/memind-clients/python/src/memind/_client.py
@@ -0,0 +1,100 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from types import TracebackType
+from typing import Any, TypeVar
+
+import httpx
+
+from memind._base_client import BaseClient
+from memind._http import RetryConfig, request_with_retries
+from memind.resources.memory import MemoryResource
+from memind.types.health import HealthResponse
+
+T = TypeVar("T")
+
+
+class MemindClient(BaseClient):
+ def __init__(
+ self,
+ *,
+ base_url: str | None = None,
+ api_token: str | None = None,
+ timeout: float | httpx.Timeout | None = None,
+ max_retries: int = 2,
+ http_client: httpx.Client | None = None,
+ ) -> None:
+ super().__init__(
+ base_url=base_url,
+ api_token=api_token,
+ timeout=timeout,
+ max_retries=max_retries,
+ )
+ self._http_client = http_client or httpx.Client(timeout=self._timeout)
+ self.memory = MemoryResource(self)
+
+ def health(self) -> HealthResponse:
+ result = self._get("/health", HealthResponse)
+ assert result is not None
+ return result
+
+ def close(self) -> None:
+ if not self._closed:
+ self._http_client.close()
+ self._mark_closed()
+
+ def __enter__(self) -> MemindClient:
+ self._ensure_open()
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ traceback: TracebackType | None,
+ ) -> None:
+ self.close()
+
+ def _get(self, path: str, response_type: type[T] | None) -> T | None:
+ self._ensure_open()
+ response = request_with_retries(
+ self._http_client,
+ "GET",
+ self._build_url(path),
+ retry_config=self._retry_config,
+ headers=self._build_headers(),
+ )
+ return self._process_response(response, response_type)
+
+ def _post(
+ self,
+ path: str,
+ body: Any,
+ response_type: type[T] | None,
+ *,
+ retry: bool = False,
+ ) -> T | None:
+ self._ensure_open()
+ retry_config = self._retry_config if retry else RetryConfig(max_retries=0)
+ response = request_with_retries(
+ self._http_client,
+ "POST",
+ self._build_url(path),
+ retry_config=retry_config,
+ headers=self._build_headers(),
+ json=self._serialize_body(body),
+ )
+ return self._process_response(response, response_type)
diff --git a/memind-clients/python/src/memind/_constants.py b/memind-clients/python/src/memind/_constants.py
new file mode 100644
index 00000000..71dd7a3a
--- /dev/null
+++ b/memind-clients/python/src/memind/_constants.py
@@ -0,0 +1,23 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+API_PREFIX = "/open/v1"
+DEFAULT_CONNECT_TIMEOUT = 5.0
+DEFAULT_MAX_RETRIES = 2
+DEFAULT_READ_TIMEOUT = 30.0
+ENV_API_TOKEN = "MEMIND_API_TOKEN"
+ENV_BASE_URL = "MEMIND_BASE_URL"
+RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
diff --git a/memind-clients/python/src/memind/_exceptions.py b/memind-clients/python/src/memind/_exceptions.py
new file mode 100644
index 00000000..726cfca4
--- /dev/null
+++ b/memind-clients/python/src/memind/_exceptions.py
@@ -0,0 +1,75 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from typing import Any
+
+
+class MemindError(Exception):
+ """Base exception for all memind client errors."""
+
+
+class MemindAPIError(MemindError):
+ """Raised when the API returns a non-success response."""
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ status_code: int,
+ error_code: str | None = None,
+ trace_id: str | None = None,
+ body: dict[str, Any] | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+ self.error_code = error_code
+ self.trace_id = trace_id
+ self.body = body
+
+
+class MemindAuthenticationError(MemindAPIError):
+ """Raised on 401 Unauthorized responses."""
+
+
+class MemindRateLimitError(MemindAPIError):
+ """Raised on 429 Too Many Requests responses."""
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ status_code: int = 429,
+ error_code: str | None = None,
+ trace_id: str | None = None,
+ body: dict[str, Any] | None = None,
+ retry_after: float | None = None,
+ ) -> None:
+ super().__init__(
+ message,
+ status_code=status_code,
+ error_code=error_code,
+ trace_id=trace_id,
+ body=body,
+ )
+ self.retry_after = retry_after
+
+
+class MemindConnectionError(MemindError):
+ """Raised when a network connection cannot be established."""
+
+
+class MemindTimeoutError(MemindError):
+ """Raised when a request times out."""
diff --git a/memind-clients/python/src/memind/_http.py b/memind-clients/python/src/memind/_http.py
new file mode 100644
index 00000000..9d84f58a
--- /dev/null
+++ b/memind-clients/python/src/memind/_http.py
@@ -0,0 +1,197 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import asyncio
+import datetime as dt
+import email.utils
+import logging
+import random
+import time
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+from memind._constants import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES
+from memind._exceptions import MemindConnectionError, MemindTimeoutError
+
+SyncSleep = Callable[[float], None]
+AsyncSleep = Callable[[float], Awaitable[None]]
+logger = logging.getLogger("memind")
+
+
+@dataclass(frozen=True)
+class RetryConfig:
+ max_retries: int = DEFAULT_MAX_RETRIES
+ initial_delay: float = 0.5
+ max_delay: float = 2.0
+ jitter: float = 0.25
+
+
+def request_with_retries(
+ client: httpx.Client,
+ method: str,
+ url: str,
+ *,
+ retry_config: RetryConfig,
+ sleep: SyncSleep = time.sleep,
+ **kwargs: Any,
+) -> httpx.Response:
+ for attempt in range(retry_config.max_retries + 1):
+ try:
+ logger.debug("%s %s", method, url)
+ response = client.request(method, url, **kwargs)
+ logger.debug("Response status: %s", response.status_code)
+ except httpx.TimeoutException as exc:
+ if attempt >= retry_config.max_retries:
+ raise MemindTimeoutError(f"Request timed out: {method} {url}") from exc
+ delay = _retry_delay(retry_config, attempt)
+ logger.warning(
+ "Retrying %s %s after timeout: attempt=%d delay=%s",
+ method,
+ url,
+ attempt + 1,
+ delay,
+ )
+ sleep(delay)
+ continue
+ except httpx.TransportError as exc:
+ if attempt >= retry_config.max_retries:
+ raise MemindConnectionError(f"Connection failed: {method} {url}") from exc
+ delay = _retry_delay(retry_config, attempt)
+ logger.warning(
+ "Retrying %s %s after transport error: attempt=%d delay=%s",
+ method,
+ url,
+ attempt + 1,
+ delay,
+ )
+ sleep(delay)
+ continue
+
+ if _should_retry_response(response) and attempt < retry_config.max_retries:
+ delay = _retry_delay(retry_config, attempt, response)
+ logger.warning(
+ "Retrying %s %s after HTTP %s: attempt=%d delay=%s",
+ method,
+ url,
+ response.status_code,
+ attempt + 1,
+ delay,
+ )
+ sleep(delay)
+ continue
+
+ return response
+
+ raise AssertionError("retry loop exhausted without returning or raising")
+
+
+async def async_request_with_retries(
+ client: httpx.AsyncClient,
+ method: str,
+ url: str,
+ *,
+ retry_config: RetryConfig,
+ sleep: AsyncSleep = asyncio.sleep,
+ **kwargs: Any,
+) -> httpx.Response:
+ for attempt in range(retry_config.max_retries + 1):
+ try:
+ logger.debug("%s %s", method, url)
+ response = await client.request(method, url, **kwargs)
+ logger.debug("Response status: %s", response.status_code)
+ except httpx.TimeoutException as exc:
+ if attempt >= retry_config.max_retries:
+ raise MemindTimeoutError(f"Request timed out: {method} {url}") from exc
+ delay = _retry_delay(retry_config, attempt)
+ logger.warning(
+ "Retrying %s %s after timeout: attempt=%d delay=%s",
+ method,
+ url,
+ attempt + 1,
+ delay,
+ )
+ await sleep(delay)
+ continue
+ except httpx.TransportError as exc:
+ if attempt >= retry_config.max_retries:
+ raise MemindConnectionError(f"Connection failed: {method} {url}") from exc
+ delay = _retry_delay(retry_config, attempt)
+ logger.warning(
+ "Retrying %s %s after transport error: attempt=%d delay=%s",
+ method,
+ url,
+ attempt + 1,
+ delay,
+ )
+ await sleep(delay)
+ continue
+
+ if _should_retry_response(response) and attempt < retry_config.max_retries:
+ delay = _retry_delay(retry_config, attempt, response)
+ logger.warning(
+ "Retrying %s %s after HTTP %s: attempt=%d delay=%s",
+ method,
+ url,
+ response.status_code,
+ attempt + 1,
+ delay,
+ )
+ await sleep(delay)
+ continue
+
+ return response
+
+ raise AssertionError("retry loop exhausted without returning or raising")
+
+
+def _should_retry_response(response: httpx.Response) -> bool:
+ return response.status_code in RETRYABLE_STATUS_CODES
+
+
+def _retry_delay(
+ retry_config: RetryConfig,
+ attempt: int,
+ response: httpx.Response | None = None,
+) -> float:
+ retry_after = _retry_after_delay(response) if response is not None else None
+ if retry_after is not None:
+ return retry_after
+
+ delay = float(min(retry_config.initial_delay * (2**attempt), retry_config.max_delay))
+ if retry_config.jitter <= 0:
+ return delay
+ return delay + random.uniform(0.0, retry_config.jitter)
+
+
+def _retry_after_delay(response: httpx.Response) -> float | None:
+ header = response.headers.get("Retry-After")
+ if not header:
+ return None
+
+ try:
+ return float(max(float(header), 0.0))
+ except ValueError:
+ try:
+ parsed = email.utils.parsedate_to_datetime(header)
+ except (TypeError, ValueError):
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
+ delta_seconds = (parsed - dt.datetime.now(dt.timezone.utc)).total_seconds()
+ return float(max(delta_seconds, 0.0))
diff --git a/memind-clients/python/src/memind/_models.py b/memind-clients/python/src/memind/_models.py
new file mode 100644
index 00000000..a5dcf8dd
--- /dev/null
+++ b/memind-clients/python/src/memind/_models.py
@@ -0,0 +1,28 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+
+class MemindModel(BaseModel):
+ """Base model for all memind types."""
+
+ model_config = ConfigDict(
+ alias_generator=to_camel,
+ extra="ignore",
+ populate_by_name=True,
+ )
diff --git a/memind-clients/python/src/memind/_version.py b/memind-clients/python/src/memind/_version.py
new file mode 100644
index 00000000..9945f84f
--- /dev/null
+++ b/memind-clients/python/src/memind/_version.py
@@ -0,0 +1,15 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+__version__ = "0.2.0"
diff --git a/memind-clients/python/src/memind/py.typed b/memind-clients/python/src/memind/py.typed
new file mode 100644
index 00000000..00e01cf7
--- /dev/null
+++ b/memind-clients/python/src/memind/py.typed
@@ -0,0 +1 @@
+# PEP 561 marker - this package supports type checking
diff --git a/memind-clients/python/src/memind/resources/__init__.py b/memind-clients/python/src/memind/resources/__init__.py
new file mode 100644
index 00000000..ab8d8048
--- /dev/null
+++ b/memind-clients/python/src/memind/resources/__init__.py
@@ -0,0 +1,18 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from memind.resources.async_memory import AsyncMemoryResource
+from memind.resources.memory import MemoryResource
+
+__all__ = ["AsyncMemoryResource", "MemoryResource"]
diff --git a/memind-clients/python/src/memind/resources/async_memory.py b/memind-clients/python/src/memind/resources/async_memory.py
new file mode 100644
index 00000000..b74a7803
--- /dev/null
+++ b/memind-clients/python/src/memind/resources/async_memory.py
@@ -0,0 +1,136 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, TypeVar
+
+from memind.types.common import Strategy
+from memind.types.memory import (
+ AddMessageRequest,
+ CommitMemoryRequest,
+ ExtractMemoryRequest,
+ RetrieveMemoryRequest,
+ RetrieveMemoryResponse,
+)
+from memind.types.message import Message, RawContentValue
+
+if TYPE_CHECKING:
+ from memind._async_client import AsyncMemindClient
+
+
+class AsyncMemoryResource:
+ def __init__(self, client: AsyncMemindClient) -> None:
+ self._client = client
+
+ async def extract(
+ self,
+ request: ExtractMemoryRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ raw_content: RawContentValue | None = None,
+ source_client: str | None = None,
+ ) -> None:
+ payload = request or _build_extract_request(
+ user_id=user_id,
+ agent_id=agent_id,
+ raw_content=raw_content,
+ source_client=source_client,
+ )
+ await self._client._post("/memory/extract", payload, None)
+
+ async def add_message(
+ self,
+ request: AddMessageRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ message: Message | None = None,
+ source_client: str | None = None,
+ ) -> None:
+ payload = request or AddMessageRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ message=_required(message, "message"),
+ source_client=source_client,
+ )
+ await self._client._post("/memory/add-message", payload, None)
+
+ async def commit(
+ self,
+ request: CommitMemoryRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ source_client: str | None = None,
+ ) -> None:
+ payload = request or CommitMemoryRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ source_client=source_client,
+ )
+ await self._client._post("/memory/commit", payload, None)
+
+ async def retrieve(
+ self,
+ request: RetrieveMemoryRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ query: str | None = None,
+ strategy: Strategy | None = None,
+ trace: bool | None = None,
+ ) -> RetrieveMemoryResponse:
+ payload = request or RetrieveMemoryRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ query=_required(query, "query"),
+ strategy=_required(strategy, "strategy"),
+ trace=trace,
+ )
+ result = await self._client._post(
+ "/memory/retrieve",
+ payload,
+ RetrieveMemoryResponse,
+ retry=True,
+ )
+ assert result is not None
+ return result
+
+
+T = TypeVar("T")
+
+
+def _required(value: T | None, name: str) -> T:
+ if value is None:
+ raise TypeError(f"{name} is required")
+ return value
+
+
+def _build_extract_request(
+ *,
+ user_id: str | None,
+ agent_id: str | None,
+ raw_content: RawContentValue | None,
+ source_client: str | None,
+) -> ExtractMemoryRequest:
+ if raw_content is None:
+ raise TypeError("raw_content is required")
+ return ExtractMemoryRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ raw_content=raw_content,
+ source_client=source_client,
+ )
diff --git a/memind-clients/python/src/memind/resources/memory.py b/memind-clients/python/src/memind/resources/memory.py
new file mode 100644
index 00000000..a3789080
--- /dev/null
+++ b/memind-clients/python/src/memind/resources/memory.py
@@ -0,0 +1,131 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, TypeVar
+
+from memind.types.common import Strategy
+from memind.types.memory import (
+ AddMessageRequest,
+ CommitMemoryRequest,
+ ExtractMemoryRequest,
+ RetrieveMemoryRequest,
+ RetrieveMemoryResponse,
+)
+from memind.types.message import Message, RawContentValue
+
+if TYPE_CHECKING:
+ from memind._client import MemindClient
+
+
+class MemoryResource:
+ def __init__(self, client: MemindClient) -> None:
+ self._client = client
+
+ def extract(
+ self,
+ request: ExtractMemoryRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ raw_content: RawContentValue | None = None,
+ source_client: str | None = None,
+ ) -> None:
+ payload = request or _build_extract_request(
+ user_id=user_id,
+ agent_id=agent_id,
+ raw_content=raw_content,
+ source_client=source_client,
+ )
+ self._client._post("/memory/extract", payload, None)
+
+ def add_message(
+ self,
+ request: AddMessageRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ message: Message | None = None,
+ source_client: str | None = None,
+ ) -> None:
+ payload = request or AddMessageRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ message=_required(message, "message"),
+ source_client=source_client,
+ )
+ self._client._post("/memory/add-message", payload, None)
+
+ def commit(
+ self,
+ request: CommitMemoryRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ source_client: str | None = None,
+ ) -> None:
+ payload = request or CommitMemoryRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ source_client=source_client,
+ )
+ self._client._post("/memory/commit", payload, None)
+
+ def retrieve(
+ self,
+ request: RetrieveMemoryRequest | None = None,
+ *,
+ user_id: str | None = None,
+ agent_id: str | None = None,
+ query: str | None = None,
+ strategy: Strategy | None = None,
+ trace: bool | None = None,
+ ) -> RetrieveMemoryResponse:
+ payload = request or RetrieveMemoryRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ query=_required(query, "query"),
+ strategy=_required(strategy, "strategy"),
+ trace=trace,
+ )
+ result = self._client._post("/memory/retrieve", payload, RetrieveMemoryResponse, retry=True)
+ assert result is not None
+ return result
+
+
+T = TypeVar("T")
+
+
+def _required(value: T | None, name: str) -> T:
+ if value is None:
+ raise TypeError(f"{name} is required")
+ return value
+
+
+def _build_extract_request(
+ *,
+ user_id: str | None,
+ agent_id: str | None,
+ raw_content: RawContentValue | None,
+ source_client: str | None,
+) -> ExtractMemoryRequest:
+ if raw_content is None:
+ raise TypeError("raw_content is required")
+ return ExtractMemoryRequest(
+ user_id=_required(user_id, "user_id"),
+ agent_id=_required(agent_id, "agent_id"),
+ raw_content=raw_content,
+ source_client=source_client,
+ )
diff --git a/memind-clients/python/src/memind/types/__init__.py b/memind-clients/python/src/memind/types/__init__.py
new file mode 100644
index 00000000..47d1cb2a
--- /dev/null
+++ b/memind-clients/python/src/memind/types/__init__.py
@@ -0,0 +1,77 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from memind.types.common import ApiResult, Role, Strategy
+from memind.types.health import HealthResponse
+from memind.types.memory import (
+ AddMessageRequest,
+ CommitMemoryRequest,
+ ExtractMemoryRequest,
+ FinalView,
+ MergeView,
+ RetrievalTraceView,
+ RetrievedInsight,
+ RetrievedItem,
+ RetrievedRawData,
+ RetrieveMemoryRequest,
+ RetrieveMemoryResponse,
+ StageView,
+)
+from memind.types.message import (
+ AudioBlock,
+ Base64Source,
+ ContentBlock,
+ ConversationContent,
+ ImageBlock,
+ MapRawContent,
+ Message,
+ RawContent,
+ RawContentValue,
+ Source,
+ TextBlock,
+ UrlSource,
+ VideoBlock,
+)
+
+__all__ = [
+ "AddMessageRequest",
+ "ApiResult",
+ "AudioBlock",
+ "Base64Source",
+ "CommitMemoryRequest",
+ "ContentBlock",
+ "ConversationContent",
+ "ExtractMemoryRequest",
+ "FinalView",
+ "HealthResponse",
+ "ImageBlock",
+ "MapRawContent",
+ "MergeView",
+ "Message",
+ "RawContent",
+ "RawContentValue",
+ "RetrievalTraceView",
+ "RetrieveMemoryRequest",
+ "RetrieveMemoryResponse",
+ "RetrievedInsight",
+ "RetrievedItem",
+ "RetrievedRawData",
+ "Role",
+ "Source",
+ "StageView",
+ "Strategy",
+ "TextBlock",
+ "UrlSource",
+ "VideoBlock",
+]
diff --git a/memind-clients/python/src/memind/types/common.py b/memind-clients/python/src/memind/types/common.py
new file mode 100644
index 00000000..3b4700c2
--- /dev/null
+++ b/memind-clients/python/src/memind/types/common.py
@@ -0,0 +1,43 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Generic, TypeVar
+
+from memind._models import MemindModel
+
+T = TypeVar("T")
+
+
+class Role(str, Enum):
+ USER = "USER"
+ ASSISTANT = "ASSISTANT"
+
+
+class Strategy(str, Enum):
+ SIMPLE = "SIMPLE"
+ DEEP = "DEEP"
+
+
+class ApiResult(MemindModel, Generic[T]):
+ code: str
+ message: str | None = None
+ data: T | None = None
+ timestamp: str | None = None
+ trace_id: str | None = None
+
+ def is_success(self) -> bool:
+ return self.code in ("200", "success")
diff --git a/memind-clients/python/src/memind/types/health.py b/memind-clients/python/src/memind/types/health.py
new file mode 100644
index 00000000..d739dfdf
--- /dev/null
+++ b/memind-clients/python/src/memind/types/health.py
@@ -0,0 +1,22 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from memind._models import MemindModel
+
+
+class HealthResponse(MemindModel):
+ status: str
+ service: str
diff --git a/memind-clients/python/src/memind/types/memory.py b/memind-clients/python/src/memind/types/memory.py
new file mode 100644
index 00000000..03a9cfe9
--- /dev/null
+++ b/memind-clients/python/src/memind/types/memory.py
@@ -0,0 +1,126 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import Field
+
+from memind._models import MemindModel
+from memind.types.common import Strategy
+from memind.types.message import Message, RawContentValue
+
+
+class ExtractMemoryRequest(MemindModel):
+ user_id: str
+ agent_id: str
+ raw_content: RawContentValue
+ source_client: str | None = None
+
+
+class AddMessageRequest(MemindModel):
+ user_id: str
+ agent_id: str
+ message: Message
+ source_client: str | None = None
+
+
+class CommitMemoryRequest(MemindModel):
+ user_id: str
+ agent_id: str
+ source_client: str | None = None
+
+
+class RetrieveMemoryRequest(MemindModel):
+ user_id: str
+ agent_id: str
+ query: str
+ strategy: Strategy
+ trace: bool | None = None
+
+
+class RetrievedItem(MemindModel):
+ id: str
+ text: str
+ vector_score: float = 0.0
+ final_score: float = 0.0
+ occurred_at: str | None = None
+
+
+class RetrievedInsight(MemindModel):
+ id: str
+ text: str
+ tier: str | None = None
+
+
+class RetrievedRawData(MemindModel):
+ raw_data_id: str
+ caption: str | None = None
+ max_score: float = 0.0
+ item_ids: list[str] | None = None
+
+
+class StageView(MemindModel):
+ stage: str | None = None
+ tier: str | None = None
+ method: str | None = None
+ status: str | None = None
+ input_count: int | None = None
+ candidate_count: int | None = None
+ result_count: int | None = None
+ degraded: bool = False
+ skipped: bool = False
+ started_at: str | None = None
+ duration_millis: int | None = None
+ attributes: dict[str, Any] | None = None
+ candidates: list[dict[str, Any]] | None = None
+
+
+class MergeView(MemindModel):
+ input_count: int = 0
+ output_count: int = 0
+ deduplicated_count: int = 0
+ source_count: int = 0
+ status: str | None = None
+
+
+class FinalView(MemindModel):
+ strategy: str | None = None
+ status: str | None = None
+ item_count: int = 0
+ insight_count: int = 0
+ raw_data_count: int = 0
+ evidence_count: int = 0
+
+
+class RetrievalTraceView(MemindModel):
+ trace_id: str | None = None
+ started_at: str | None = None
+ completed_at: str | None = None
+ truncated: bool | None = None
+ stages: list[StageView] = Field(default_factory=list)
+ merge: MergeView | None = None
+ final_results: FinalView | None = None
+
+
+class RetrieveMemoryResponse(MemindModel):
+ status: str | None = None
+ items: list[RetrievedItem] = Field(default_factory=list)
+ insights: list[RetrievedInsight] = Field(default_factory=list)
+ raw_data: list[RetrievedRawData] = Field(default_factory=list)
+ evidences: list[str] = Field(default_factory=list)
+ strategy: str | None = None
+ query: str | None = None
+ trace: RetrievalTraceView | None = None
diff --git a/memind-clients/python/src/memind/types/message.py b/memind-clients/python/src/memind/types/message.py
new file mode 100644
index 00000000..4b5500c3
--- /dev/null
+++ b/memind-clients/python/src/memind/types/message.py
@@ -0,0 +1,112 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from typing import Annotated, Any, Literal
+
+from pydantic import Field, model_serializer, model_validator
+
+from memind._models import MemindModel
+from memind.types.common import Role
+
+
+class UrlSource(MemindModel):
+ type: Literal["url"] = "url"
+ url: str
+
+
+class Base64Source(MemindModel):
+ type: Literal["base64"] = "base64"
+ media_type: str = Field(alias="media_type")
+ data: str
+
+
+Source = Annotated[UrlSource | Base64Source, Field(discriminator="type")]
+
+
+class TextBlock(MemindModel):
+ type: Literal["text"] = "text"
+ text: str
+
+
+class ImageBlock(MemindModel):
+ type: Literal["image"] = "image"
+ source: Source
+
+
+class AudioBlock(MemindModel):
+ type: Literal["audio"] = "audio"
+ source: Source
+
+
+class VideoBlock(MemindModel):
+ type: Literal["video"] = "video"
+ source: Source
+
+
+ContentBlock = Annotated[
+ TextBlock | ImageBlock | AudioBlock | VideoBlock,
+ Field(discriminator="type"),
+]
+
+
+class Message(MemindModel):
+ role: Role
+ content: list[ContentBlock]
+ timestamp: str | None = None
+ user_name: str | None = None
+ source_client: str | None = None
+
+ @classmethod
+ def user(cls, text: str, *, timestamp: str | None = None) -> Message:
+ return cls(role=Role.USER, content=[TextBlock(text=text)], timestamp=timestamp)
+
+ @classmethod
+ def assistant(cls, text: str, *, timestamp: str | None = None) -> Message:
+ return cls(role=Role.ASSISTANT, content=[TextBlock(text=text)], timestamp=timestamp)
+
+
+class RawContent(MemindModel):
+ type: str
+
+
+class ConversationContent(RawContent):
+ type: Literal["conversation"] = "conversation"
+ messages: list[Message]
+
+
+class MapRawContent(RawContent):
+ type: str
+ properties: dict[str, Any] = Field(default_factory=dict)
+
+ @model_serializer
+ def _serialize(self) -> dict[str, Any]:
+ result: dict[str, Any] = {"type": self.type}
+ result.update(self.properties)
+ return result
+
+ @model_validator(mode="before")
+ @classmethod
+ def _parse_flat_fields(cls, data: Any) -> Any:
+ if isinstance(data, dict):
+ if "properties" in data:
+ return data
+ type_val = data.get("type", "")
+ properties = {key: value for key, value in data.items() if key != "type"}
+ return {"type": type_val, "properties": properties}
+ return data
+
+
+RawContentValue = ConversationContent | MapRawContent
diff --git a/memind-clients/python/tests/conftest.py b/memind-clients/python/tests/conftest.py
new file mode 100644
index 00000000..c119421f
--- /dev/null
+++ b/memind-clients/python/tests/conftest.py
@@ -0,0 +1,15 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
diff --git a/memind-clients/python/tests/test_async_client.py b/memind-clients/python/tests/test_async_client.py
new file mode 100644
index 00000000..49bfc2e5
--- /dev/null
+++ b/memind-clients/python/tests/test_async_client.py
@@ -0,0 +1,172 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import pytest
+
+from memind._async_client import AsyncMemindClient
+from memind._exceptions import MemindAPIError, MemindError
+from memind.types import ConversationContent, Message, Strategy
+
+
+@pytest.mark.asyncio
+async def test_async_health_returns_response(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="GET",
+ url="https://api.example.test/open/v1/health",
+ json={"code": "success", "data": {"status": "UP", "service": "memind-server"}},
+ )
+
+ async with AsyncMemindClient(base_url="https://api.example.test") as client:
+ health = await client.health()
+
+ assert health.status == "UP"
+
+
+@pytest.mark.asyncio
+async def test_async_memory_methods_send_payloads(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/extract",
+ json={"code": "200"},
+ )
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/add-message",
+ json={"code": "200"},
+ )
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/commit",
+ json={"code": "200"},
+ )
+
+ client = AsyncMemindClient(base_url="https://api.example.test")
+ await client.memory.extract(
+ user_id="u1",
+ agent_id="a1",
+ raw_content=ConversationContent(messages=[Message.user("hello")]),
+ )
+ await client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello"))
+ await client.memory.commit(user_id="u1", agent_id="a1")
+ await client.close()
+
+ requests = httpx_mock.get_requests()
+ assert len(requests) == 3
+ assert b'"rawContent":{"type":"conversation"' in requests[0].content
+ assert b'"message":{"role":"USER"' in requests[1].content
+ assert b'"userId":"u1"' in requests[2].content
+
+
+@pytest.mark.asyncio
+async def test_async_retrieve_returns_response(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ json={
+ "code": "success",
+ "data": {
+ "status": "success",
+ "items": [{"id": "1", "text": "likes coffee"}],
+ "insights": [],
+ "rawData": [],
+ "evidences": [],
+ "strategy": "DEEP",
+ "query": "coffee",
+ },
+ },
+ )
+
+ client = AsyncMemindClient(base_url="https://api.example.test")
+ result = await client.memory.retrieve(
+ user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.DEEP
+ )
+ await client.close()
+
+ assert result.items[0].text == "likes coffee"
+
+
+@pytest.mark.asyncio
+async def test_async_api_error_is_raised_unwrapped(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ status_code=400,
+ json={"code": "bad_request", "message": "query is required"},
+ )
+
+ client = AsyncMemindClient(base_url="https://api.example.test")
+ with pytest.raises(MemindAPIError):
+ await client.memory.retrieve(
+ user_id="u1", agent_id="a1", query="", strategy=Strategy.SIMPLE
+ )
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_async_close_then_call_raises_memind_error() -> None:
+ client = AsyncMemindClient(base_url="https://api.example.test")
+ await client.close()
+
+ with pytest.raises(MemindError, match="closed"):
+ await client.health()
+
+
+@pytest.mark.asyncio
+async def test_async_mutating_post_methods_do_not_retry_by_default(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/add-message",
+ status_code=503,
+ json={"code": "unavailable"},
+ )
+
+ client = AsyncMemindClient(base_url="https://api.example.test", max_retries=2)
+ with pytest.raises(MemindAPIError) as exc_info:
+ await client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello"))
+
+ assert exc_info.value.status_code == 503
+ assert len(httpx_mock.get_requests()) == 1
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_async_retrieve_retries_by_default(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ status_code=503,
+ json={"code": "unavailable"},
+ )
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ json={
+ "code": "success",
+ "data": {"items": [], "insights": [], "rawData": [], "evidences": []},
+ },
+ )
+
+ client = AsyncMemindClient(base_url="https://api.example.test", max_retries=1)
+ result = await client.memory.retrieve(
+ user_id="u1",
+ agent_id="a1",
+ query="coffee",
+ strategy=Strategy.SIMPLE,
+ )
+
+ assert result.items == []
+ assert len(httpx_mock.get_requests()) == 2
+ await client.close()
diff --git a/memind-clients/python/tests/test_base_client.py b/memind-clients/python/tests/test_base_client.py
new file mode 100644
index 00000000..421c72f2
--- /dev/null
+++ b/memind-clients/python/tests/test_base_client.py
@@ -0,0 +1,188 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from typing import Any, TypeVar
+
+import httpx
+import pytest
+from pydantic import BaseModel
+
+from memind._base_client import BaseClient
+from memind._exceptions import (
+ MemindAPIError,
+ MemindAuthenticationError,
+ MemindError,
+ MemindRateLimitError,
+)
+from memind.types.health import HealthResponse
+
+T = TypeVar("T")
+
+
+class RequiredFieldResponse(BaseModel):
+ required_field: str
+
+
+class InspectableClient(BaseClient):
+ def headers(self) -> dict[str, str]:
+ return self._build_headers()
+
+ def url(self, path: str) -> str:
+ return self._build_url(path)
+
+ def process(self, response: httpx.Response, response_type: type[T] | None) -> T | None:
+ return self._process_response(response, response_type)
+
+
+def make_response(status_code: int, payload: dict[str, Any]) -> httpx.Response:
+ return httpx.Response(
+ status_code,
+ json=payload,
+ request=httpx.Request("GET", "https://x"),
+ )
+
+
+def test_base_url_required(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("MEMIND_BASE_URL", raising=False)
+ with pytest.raises(MemindError, match="base_url"):
+ InspectableClient()
+
+
+def test_blank_constructor_base_url_does_not_fall_back_to_env(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("MEMIND_BASE_URL", "https://env.example.test")
+ with pytest.raises(MemindError, match="base_url"):
+ InspectableClient(base_url=" ")
+
+
+def test_negative_max_retries_is_rejected() -> None:
+ with pytest.raises(MemindError, match="max_retries"):
+ InspectableClient(base_url="https://api.example.test", max_retries=-1)
+
+
+def test_base_url_can_come_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("MEMIND_BASE_URL", "https://api.example.test/")
+ client = InspectableClient()
+ assert client.url("/health") == "https://api.example.test/open/v1/health"
+
+
+def test_constructor_config_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("MEMIND_BASE_URL", "https://env.example.test")
+ client = InspectableClient(base_url="https://arg.example.test/base/")
+ assert client.url("memory/retrieve") == "https://arg.example.test/base/open/v1/memory/retrieve"
+
+
+def test_headers_include_user_agent_and_optional_auth() -> None:
+ client = InspectableClient(base_url="https://api.example.test", api_token=" sk-test ")
+ headers = client.headers()
+ assert headers["Accept"] == "application/json"
+ assert headers["Content-Type"] == "application/json"
+ assert headers["Authorization"] == "Bearer sk-test"
+ assert headers["User-Agent"].startswith("memind-python/")
+
+
+def test_headers_skip_empty_token() -> None:
+ client = InspectableClient(base_url="https://api.example.test", api_token=" ")
+ assert "Authorization" not in client.headers()
+
+
+def test_process_success_response_returns_typed_data() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = make_response(
+ 200,
+ {"code": "success", "data": {"status": "UP", "service": "memind-server"}},
+ )
+ result = client.process(response, HealthResponse)
+ assert isinstance(result, HealthResponse)
+ assert result.status == "UP"
+
+
+def test_process_success_response_without_data_returns_none() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = make_response(200, {"code": "200"})
+ assert client.process(response, None) is None
+
+
+def test_process_success_response_data_validation_error_is_api_error() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = make_response(200, {"code": "success", "data": {"unexpected": "value"}})
+ with pytest.raises(MemindAPIError) as exc_info:
+ client.process(response, RequiredFieldResponse)
+
+ assert exc_info.value.status_code == 200
+ assert exc_info.value.error_code == "parse_error"
+ assert exc_info.value.body == {"unexpected": "value"}
+
+
+def test_process_api_error_raises_api_error() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = make_response(
+ 400,
+ {"code": "bad_request", "message": "query is required", "traceId": "trace-1"},
+ )
+ with pytest.raises(MemindAPIError) as exc_info:
+ client.process(response, HealthResponse)
+
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.error_code == "bad_request"
+ assert exc_info.value.trace_id == "trace-1"
+ assert exc_info.value.body["message"] == "query is required"
+
+
+def test_process_authentication_error() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = make_response(401, {"code": "unauthorized", "message": "bad token"})
+ with pytest.raises(MemindAuthenticationError):
+ client.process(response, None)
+
+
+def test_process_rate_limit_error() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = httpx.Response(
+ 429,
+ json={"code": "rate_limited", "message": "slow down"},
+ headers={"Retry-After": "3"},
+ request=httpx.Request("POST", "https://x"),
+ )
+ with pytest.raises(MemindRateLimitError) as exc_info:
+ client.process(response, None)
+
+ assert exc_info.value.retry_after == 3.0
+
+
+def test_process_rate_limit_error_accepts_http_date_retry_after() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = httpx.Response(
+ 429,
+ json={"code": "rate_limited", "message": "slow down"},
+ headers={"Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT"},
+ request=httpx.Request("POST", "https://x"),
+ )
+ with pytest.raises(MemindRateLimitError) as exc_info:
+ client.process(response, None)
+
+ assert exc_info.value.retry_after is not None
+ assert exc_info.value.retry_after > 0
+
+
+def test_process_parse_error() -> None:
+ client = InspectableClient(base_url="https://api.example.test")
+ response = httpx.Response(502, content=b"not json", request=httpx.Request("GET", "https://x"))
+ with pytest.raises(MemindAPIError) as exc_info:
+ client.process(response, None)
+
+ assert exc_info.value.error_code == "parse_error"
diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py
new file mode 100644
index 00000000..e000ef91
--- /dev/null
+++ b/memind-clients/python/tests/test_client.py
@@ -0,0 +1,201 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import pytest
+
+from memind._client import MemindClient
+from memind._exceptions import MemindAPIError, MemindError
+from memind.types import ConversationContent, Message, RetrieveMemoryRequest, Strategy
+
+
+def test_health_returns_response(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="GET",
+ url="https://api.example.test/open/v1/health",
+ json={"code": "success", "data": {"status": "UP", "service": "memind-server"}},
+ )
+
+ with MemindClient(base_url="https://api.example.test") as client:
+ health = client.health()
+
+ assert health.status == "UP"
+ request = httpx_mock.get_request()
+ assert request.headers["User-Agent"].startswith("memind-python/")
+
+
+def test_add_message_sends_payload_and_auth_header(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/add-message",
+ json={"code": "200"},
+ )
+
+ client = MemindClient(base_url="https://api.example.test", api_token="sk-test")
+ client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello"))
+
+ request = httpx_mock.get_request()
+ assert request.headers["Authorization"] == "Bearer sk-test"
+ assert b'"userId":"u1"' in request.content
+ assert b'"message":{"role":"USER"' in request.content
+ client.close()
+
+
+def test_extract_sends_raw_content(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/extract",
+ json={"code": "200"},
+ )
+
+ client = MemindClient(base_url="https://api.example.test")
+ client.memory.extract(
+ user_id="u1",
+ agent_id="a1",
+ raw_content=ConversationContent(messages=[Message.user("hello")]),
+ )
+
+ assert b'"rawContent":{"type":"conversation"' in httpx_mock.get_request().content
+ client.close()
+
+
+def test_commit_sends_payload(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/commit",
+ json={"code": "200"},
+ )
+
+ client = MemindClient(base_url="https://api.example.test")
+ client.memory.commit(user_id="u1", agent_id="a1")
+
+ assert b'"userId":"u1"' in httpx_mock.get_request().content
+ client.close()
+
+
+def test_retrieve_accepts_expanded_parameters(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ json={
+ "code": "success",
+ "data": {
+ "status": "success",
+ "items": [{"id": "1", "text": "likes coffee", "vectorScore": 0.9}],
+ "insights": [],
+ "rawData": [],
+ "evidences": [],
+ "strategy": "SIMPLE",
+ "query": "coffee",
+ },
+ },
+ )
+
+ client = MemindClient(base_url="https://api.example.test")
+ result = client.memory.retrieve(
+ user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.SIMPLE, trace=True
+ )
+
+ assert result.items[0].text == "likes coffee"
+ assert b'"trace":true' in httpx_mock.get_request().content
+ client.close()
+
+
+def test_retrieve_accepts_request_object(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ json={
+ "code": "success",
+ "data": {"items": [], "insights": [], "rawData": [], "evidences": []},
+ },
+ )
+
+ client = MemindClient(base_url="https://api.example.test")
+ request = RetrieveMemoryRequest(
+ user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.DEEP
+ )
+ result = client.memory.retrieve(request)
+
+ assert result.items == []
+ client.close()
+
+
+def test_api_error_is_raised_unwrapped(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ status_code=400,
+ json={"code": "bad_request", "message": "query is required", "traceId": "t1"},
+ )
+
+ client = MemindClient(base_url="https://api.example.test")
+ with pytest.raises(MemindAPIError) as exc_info:
+ client.memory.retrieve(user_id="u1", agent_id="a1", query="", strategy=Strategy.SIMPLE)
+
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.error_code == "bad_request"
+ client.close()
+
+
+def test_close_then_call_raises_memind_error() -> None:
+ client = MemindClient(base_url="https://api.example.test")
+ client.close()
+
+ with pytest.raises(MemindError, match="closed"):
+ client.health()
+
+
+def test_mutating_post_methods_do_not_retry_by_default(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/add-message",
+ status_code=503,
+ json={"code": "unavailable"},
+ )
+
+ client = MemindClient(base_url="https://api.example.test", max_retries=2)
+ with pytest.raises(MemindAPIError) as exc_info:
+ client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello"))
+
+ assert exc_info.value.status_code == 503
+ assert len(httpx_mock.get_requests()) == 1
+ client.close()
+
+
+def test_retrieve_retries_by_default(httpx_mock) -> None:
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ status_code=503,
+ json={"code": "unavailable"},
+ )
+ httpx_mock.add_response(
+ method="POST",
+ url="https://api.example.test/open/v1/memory/retrieve",
+ json={
+ "code": "success",
+ "data": {"items": [], "insights": [], "rawData": [], "evidences": []},
+ },
+ )
+
+ client = MemindClient(base_url="https://api.example.test", max_retries=1)
+ result = client.memory.retrieve(
+ user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.SIMPLE
+ )
+
+ assert result.items == []
+ assert len(httpx_mock.get_requests()) == 2
+ client.close()
diff --git a/memind-clients/python/tests/test_exceptions.py b/memind-clients/python/tests/test_exceptions.py
new file mode 100644
index 00000000..331ccd42
--- /dev/null
+++ b/memind-clients/python/tests/test_exceptions.py
@@ -0,0 +1,72 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+from memind._exceptions import (
+ MemindAPIError,
+ MemindAuthenticationError,
+ MemindConnectionError,
+ MemindError,
+ MemindRateLimitError,
+ MemindTimeoutError,
+)
+
+
+class TestMemindError:
+ def test_base_exception(self) -> None:
+ err = MemindError("something went wrong")
+ assert str(err) == "something went wrong"
+ assert isinstance(err, Exception)
+
+ def test_api_error(self) -> None:
+ err = MemindAPIError(
+ "Not Found",
+ status_code=404,
+ error_code="RESOURCE_NOT_FOUND",
+ trace_id="trace-123",
+ body={"code": "RESOURCE_NOT_FOUND", "message": "Not Found"},
+ )
+ assert err.status_code == 404
+ assert err.error_code == "RESOURCE_NOT_FOUND"
+ assert err.trace_id == "trace-123"
+ assert err.body == {"code": "RESOURCE_NOT_FOUND", "message": "Not Found"}
+ assert isinstance(err, MemindError)
+
+ def test_authentication_error(self) -> None:
+ err = MemindAuthenticationError("Unauthorized", status_code=401, error_code="UNAUTHORIZED")
+ assert err.status_code == 401
+ assert isinstance(err, MemindAPIError)
+ assert isinstance(err, MemindError)
+
+ def test_rate_limit_error(self) -> None:
+ err = MemindRateLimitError(
+ "Too Many Requests",
+ status_code=429,
+ error_code="RATE_LIMITED",
+ retry_after=2.5,
+ )
+ assert err.status_code == 429
+ assert err.retry_after == 2.5
+ assert isinstance(err, MemindAPIError)
+
+ def test_connection_error(self) -> None:
+ err = MemindConnectionError("Connection refused")
+ assert isinstance(err, MemindError)
+ assert not isinstance(err, MemindAPIError)
+
+ def test_timeout_error(self) -> None:
+ err = MemindTimeoutError("Request timed out")
+ assert isinstance(err, MemindError)
+ assert not isinstance(err, MemindAPIError)
diff --git a/memind-clients/python/tests/test_models.py b/memind-clients/python/tests/test_models.py
new file mode 100644
index 00000000..db3c8af0
--- /dev/null
+++ b/memind-clients/python/tests/test_models.py
@@ -0,0 +1,335 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from memind._models import MemindModel
+from memind.types.common import ApiResult, Role, Strategy
+from memind.types.health import HealthResponse
+from memind.types.memory import (
+ AddMessageRequest,
+ CommitMemoryRequest,
+ ExtractMemoryRequest,
+ RetrieveMemoryRequest,
+ RetrieveMemoryResponse,
+)
+from memind.types.message import (
+ Base64Source,
+ ConversationContent,
+ ImageBlock,
+ MapRawContent,
+ Message,
+ TextBlock,
+ UrlSource,
+)
+
+
+class TestMemindModel:
+ def test_camel_case_alias(self) -> None:
+ class Sample(MemindModel):
+ user_id: str
+ agent_id: str
+
+ obj = Sample(user_id="u1", agent_id="a1")
+ assert obj.model_dump(by_alias=True) == {"userId": "u1", "agentId": "a1"}
+
+ def test_populate_by_name(self) -> None:
+ class Sample(MemindModel):
+ user_id: str
+
+ obj = Sample(user_id="u1")
+ assert obj.user_id == "u1"
+
+ def test_from_camel_case_json(self) -> None:
+ class Sample(MemindModel):
+ user_id: str
+ source_client: str | None = None
+
+ obj = Sample.model_validate({"userId": "u1", "sourceClient": "web"})
+ assert obj.user_id == "u1"
+ assert obj.source_client == "web"
+
+ def test_extra_fields_ignored(self) -> None:
+ class Sample(MemindModel):
+ user_id: str
+
+ obj = Sample.model_validate({"userId": "u1", "unknownField": "ignored"})
+ assert obj.user_id == "u1"
+
+ def test_exclude_none(self) -> None:
+ class Sample(MemindModel):
+ user_id: str
+ optional_field: str | None = None
+
+ obj = Sample(user_id="u1")
+ dumped = obj.model_dump(by_alias=True, exclude_none=True)
+ assert "optionalField" not in dumped
+
+
+class TestCommonTypes:
+ def test_role_values(self) -> None:
+ assert Role.USER == "USER"
+ assert Role.ASSISTANT == "ASSISTANT"
+
+ def test_strategy_values(self) -> None:
+ assert Strategy.SIMPLE == "SIMPLE"
+ assert Strategy.DEEP == "DEEP"
+
+ def test_strategy_json_serialization(self) -> None:
+ assert json.loads(json.dumps(Strategy.SIMPLE)) == "SIMPLE"
+
+ def test_success_result(self) -> None:
+ data: dict[str, Any] = {
+ "code": "200",
+ "message": None,
+ "data": {"status": "UP", "service": "memind-server"},
+ "timestamp": "2026-01-01T00:00:00Z",
+ "traceId": "trace-1",
+ }
+ result = ApiResult[dict[str, str]].model_validate(data)
+ assert result.code == "200"
+ assert result.data == {"status": "UP", "service": "memind-server"}
+ assert result.trace_id == "trace-1"
+ assert result.is_success()
+
+ def test_unknown_fields_ignored(self) -> None:
+ data: dict[str, Any] = {"code": "200", "data": None, "extraField": "ignored"}
+ result = ApiResult[None].model_validate(data)
+ assert result.code == "200"
+
+
+class TestMessageTypes:
+ def test_url_source_serialization(self) -> None:
+ source = UrlSource(url="https://example.com/img.png")
+ dumped = source.model_dump(by_alias=True, exclude_none=True)
+ assert dumped == {"type": "url", "url": "https://example.com/img.png"}
+
+ def test_base64_source_media_type_alias(self) -> None:
+ source = Base64Source(media_type="image/png", data="abc123")
+ dumped = source.model_dump(by_alias=True, exclude_none=True)
+ assert dumped == {"type": "base64", "media_type": "image/png", "data": "abc123"}
+
+ def test_base64_source_from_json(self) -> None:
+ data = {"type": "base64", "media_type": "image/png", "data": "abc123"}
+ source = Base64Source.model_validate(data)
+ assert source.media_type == "image/png"
+ assert source.data == "abc123"
+
+ def test_text_block(self) -> None:
+ block = TextBlock(text="hello")
+ dumped = block.model_dump(by_alias=True, exclude_none=True)
+ assert dumped == {"type": "text", "text": "hello"}
+
+ def test_content_block_discriminated_union_from_json(self) -> None:
+ msg = Message.model_validate(
+ {
+ "role": "USER",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image",
+ "source": {"type": "url", "url": "https://img.com/a.png"},
+ },
+ ],
+ }
+ )
+ assert isinstance(msg.content[0], TextBlock)
+ assert isinstance(msg.content[1], ImageBlock)
+ assert isinstance(msg.content[1].source, UrlSource)
+
+ def test_user_factory(self) -> None:
+ msg = Message.user("hello")
+ assert msg.role == Role.USER
+ assert len(msg.content) == 1
+ assert isinstance(msg.content[0], TextBlock)
+ assert msg.content[0].text == "hello"
+
+ def test_assistant_factory(self) -> None:
+ msg = Message.assistant("hi there", timestamp="2026-01-01T00:00:00Z")
+ assert msg.role == Role.ASSISTANT
+ assert msg.timestamp == "2026-01-01T00:00:00Z"
+
+ def test_message_serialization(self) -> None:
+ msg = Message.user("hello")
+ dumped = msg.model_dump(by_alias=True, exclude_none=True)
+ assert dumped["role"] == "USER"
+ assert dumped["content"] == [{"type": "text", "text": "hello"}]
+ assert "userName" not in dumped
+ assert "timestamp" not in dumped
+
+ def test_conversation_content(self) -> None:
+ content = ConversationContent(messages=[Message.user("hi")])
+ dumped = content.model_dump(by_alias=True, exclude_none=True)
+ assert dumped["type"] == "conversation"
+ assert len(dumped["messages"]) == 1
+
+ def test_map_raw_content_serialization(self) -> None:
+ content = MapRawContent(type="document", properties={"title": "Test", "body": "Content"})
+ dumped = content.model_dump(by_alias=True, exclude_none=True)
+ assert dumped == {"type": "document", "title": "Test", "body": "Content"}
+
+ def test_map_raw_content_from_json(self) -> None:
+ data = {"type": "document", "title": "Test", "body": "Content"}
+ content = MapRawContent.model_validate(data)
+ assert content.type == "document"
+ assert content.properties == {"title": "Test", "body": "Content"}
+
+
+class TestHealthAndMemoryModels:
+ def test_health_response_from_json(self) -> None:
+ data = {"status": "UP", "service": "memind-server"}
+ resp = HealthResponse.model_validate(data)
+ assert resp.status == "UP"
+ assert resp.service == "memind-server"
+
+ def test_extract_memory_request(self) -> None:
+ req = ExtractMemoryRequest(
+ user_id="u1",
+ agent_id="a1",
+ raw_content=ConversationContent(messages=[Message.user("hi")]),
+ )
+ dumped = req.model_dump(by_alias=True, exclude_none=True)
+ assert dumped["userId"] == "u1"
+ assert dumped["agentId"] == "a1"
+ assert dumped["rawContent"]["type"] == "conversation"
+ assert dumped["rawContent"]["messages"][0]["role"] == "USER"
+ assert dumped["rawContent"]["messages"][0]["content"][0]["text"] == "hi"
+ assert "sourceClient" not in dumped
+
+ def test_add_message_request(self) -> None:
+ req = AddMessageRequest(
+ user_id="u1",
+ agent_id="a1",
+ message=Message.user("hello"),
+ source_client="python-sdk",
+ )
+ dumped = req.model_dump(by_alias=True, exclude_none=True)
+ assert dumped["userId"] == "u1"
+ assert dumped["message"]["role"] == "USER"
+ assert dumped["sourceClient"] == "python-sdk"
+
+ def test_commit_memory_request(self) -> None:
+ req = CommitMemoryRequest(user_id="u1", agent_id="a1")
+ dumped = req.model_dump(by_alias=True, exclude_none=True)
+ assert dumped == {"userId": "u1", "agentId": "a1"}
+
+ def test_retrieve_memory_request(self) -> None:
+ req = RetrieveMemoryRequest(
+ user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.DEEP, trace=True
+ )
+ dumped = req.model_dump(by_alias=True, exclude_none=True)
+ assert dumped["strategy"] == "DEEP"
+ assert dumped["trace"] is True
+
+ def test_retrieve_memory_response(self) -> None:
+ data = {
+ "status": "OK",
+ "items": [
+ {
+ "id": "item-1",
+ "text": "likes coffee",
+ "vectorScore": 0.95,
+ "finalScore": 0.88,
+ "occurredAt": "2026-01-01T00:00:00Z",
+ }
+ ],
+ "insights": [{"id": "ins-1", "text": "prefers hot drinks", "tier": "CORE"}],
+ "rawData": [
+ {"rawDataId": "rd-1", "caption": "chat", "maxScore": 0.9, "itemIds": ["item-1"]}
+ ],
+ "evidences": ["evidence-1"],
+ "strategy": "SIMPLE",
+ "query": "coffee",
+ }
+ resp = RetrieveMemoryResponse.model_validate(data)
+ assert resp.status == "OK"
+ assert len(resp.items) == 1
+ assert resp.items[0].id == "item-1"
+ assert resp.items[0].vector_score == 0.95
+ assert resp.items[0].final_score == 0.88
+ assert resp.items[0].occurred_at == "2026-01-01T00:00:00Z"
+ assert resp.insights[0].tier == "CORE"
+ assert resp.raw_data[0].raw_data_id == "rd-1"
+ assert resp.evidences == ["evidence-1"]
+
+ def test_retrieve_memory_response_with_trace(self) -> None:
+ data = {
+ "status": "OK",
+ "items": [],
+ "insights": [],
+ "rawData": [],
+ "evidences": [],
+ "strategy": "DEEP",
+ "query": "test",
+ "trace": {
+ "traceId": "t-1",
+ "startedAt": "2026-01-01T00:00:00Z",
+ "completedAt": "2026-01-01T00:00:01Z",
+ "truncated": False,
+ "stages": [
+ {
+ "stage": "vector_search",
+ "method": "embedding",
+ "status": "COMPLETED",
+ "inputCount": 1,
+ "candidateCount": 10,
+ "resultCount": 5,
+ "degraded": False,
+ "skipped": False,
+ "durationMillis": 120,
+ }
+ ],
+ "merge": {
+ "inputCount": 5,
+ "outputCount": 4,
+ "deduplicatedCount": 1,
+ "sourceCount": 2,
+ "status": "COMPLETED",
+ },
+ "finalResults": {
+ "strategy": "DEEP",
+ "status": "COMPLETED",
+ "itemCount": 4,
+ "insightCount": 2,
+ "rawDataCount": 1,
+ "evidenceCount": 3,
+ },
+ },
+ }
+ resp = RetrieveMemoryResponse.model_validate(data)
+ assert resp.trace is not None
+ assert resp.trace.trace_id == "t-1"
+ assert len(resp.trace.stages) == 1
+ assert resp.trace.stages[0].duration_millis == 120
+ assert resp.trace.merge is not None
+ assert resp.trace.merge.deduplicated_count == 1
+ assert resp.trace.final_results is not None
+ assert resp.trace.final_results.item_count == 4
+
+ def test_response_ignores_unknown_fields(self) -> None:
+ data = {
+ "status": "OK",
+ "items": [],
+ "insights": [],
+ "rawData": [],
+ "evidences": [],
+ "strategy": "SIMPLE",
+ "query": "test",
+ "futureField": "should be ignored",
+ }
+ resp = RetrieveMemoryResponse.model_validate(data)
+ assert resp.status == "OK"
diff --git a/memind-clients/python/tests/test_public_api.py b/memind-clients/python/tests/test_public_api.py
new file mode 100644
index 00000000..d7621fd8
--- /dev/null
+++ b/memind-clients/python/tests/test_public_api.py
@@ -0,0 +1,39 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import memind
+from memind import (
+ AsyncMemindClient,
+ ConversationContent,
+ MemindAPIError,
+ MemindClient,
+ MemindError,
+ Message,
+ RawContentValue,
+ Strategy,
+)
+
+
+def test_public_exports() -> None:
+ assert memind.__version__ == "0.2.0"
+ assert MemindClient is not None
+ assert AsyncMemindClient is not None
+ assert MemindError is not None
+ assert MemindAPIError is not None
+ assert RawContentValue is not None
+ assert Message.user("hello").role.value == "USER"
+ assert Strategy.SIMPLE.value == "SIMPLE"
+ assert ConversationContent(messages=[Message.user("hi")]).type == "conversation"
diff --git a/memind-clients/python/tests/test_retry.py b/memind-clients/python/tests/test_retry.py
new file mode 100644
index 00000000..a01bc89b
--- /dev/null
+++ b/memind-clients/python/tests/test_retry.py
@@ -0,0 +1,180 @@
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from memind._exceptions import MemindConnectionError, MemindTimeoutError
+from memind._http import RetryConfig, async_request_with_retries, request_with_retries
+
+
+def test_sync_request_retries_503_then_returns_success(httpx_mock) -> None:
+ sleeps: list[float] = []
+ httpx_mock.add_response(status_code=503, json={"code": "unavailable"})
+ httpx_mock.add_response(status_code=200, json={"code": "success", "data": {"ok": True}})
+
+ with httpx.Client() as client:
+ response = request_with_retries(
+ client,
+ "GET",
+ "https://api.example.test/open/v1/health",
+ retry_config=RetryConfig(max_retries=2, jitter=0.0),
+ sleep=sleeps.append,
+ )
+
+ assert response.status_code == 200
+ assert sleeps == [0.5]
+
+
+def test_retry_logs_without_payload_or_authorization(httpx_mock, caplog) -> None:
+ sleeps: list[float] = []
+ httpx_mock.add_response(status_code=503, json={"code": "unavailable"})
+ httpx_mock.add_response(status_code=200, json={"code": "success"})
+
+ with httpx.Client() as client:
+ with caplog.at_level("WARNING", logger="memind"):
+ request_with_retries(
+ client,
+ "POST",
+ "https://api.example.test/open/v1/memory/retrieve",
+ retry_config=RetryConfig(max_retries=1, jitter=0.0),
+ sleep=sleeps.append,
+ headers={"Authorization": "Bearer secret-token"},
+ json={"query": "private payload"},
+ )
+
+ log_text = caplog.text
+ assert (
+ "Retrying POST https://api.example.test/open/v1/memory/retrieve after HTTP 503" in log_text
+ )
+ assert "secret-token" not in log_text
+ assert "private payload" not in log_text
+
+
+def test_sync_request_does_not_retry_when_disabled(httpx_mock) -> None:
+ sleeps: list[float] = []
+ httpx_mock.add_response(status_code=503, json={"code": "unavailable"})
+
+ with httpx.Client() as client:
+ response = request_with_retries(
+ client,
+ "POST",
+ "https://api.example.test/open/v1/memory/add-message",
+ retry_config=RetryConfig(max_retries=0, jitter=0.0),
+ sleep=sleeps.append,
+ json={"message": {"role": "USER", "content": [{"type": "text", "text": "hello"}]}},
+ )
+
+ assert response.status_code == 503
+ assert sleeps == []
+ assert len(httpx_mock.get_requests()) == 1
+
+
+def test_sync_request_respects_retry_after_header(httpx_mock) -> None:
+ sleeps: list[float] = []
+ httpx_mock.add_response(status_code=429, headers={"Retry-After": "2"})
+ httpx_mock.add_response(status_code=200, json={"code": "success"})
+
+ with httpx.Client() as client:
+ response = request_with_retries(
+ client,
+ "POST",
+ "https://api.example.test/open/v1/memory/retrieve",
+ retry_config=RetryConfig(max_retries=2, jitter=0.0),
+ sleep=sleeps.append,
+ json={"query": "coffee"},
+ )
+
+ assert response.status_code == 200
+ assert sleeps == [2.0]
+
+
+def test_sync_request_ignores_invalid_retry_after_header(httpx_mock) -> None:
+ sleeps: list[float] = []
+ httpx_mock.add_response(status_code=429, headers={"Retry-After": "not-a-date"})
+ httpx_mock.add_response(status_code=200, json={"code": "success"})
+
+ with httpx.Client() as client:
+ response = request_with_retries(
+ client,
+ "POST",
+ "https://api.example.test/open/v1/memory/retrieve",
+ retry_config=RetryConfig(max_retries=2, jitter=0.0),
+ sleep=sleeps.append,
+ json={"query": "coffee"},
+ )
+
+ assert response.status_code == 200
+ assert sleeps == [0.5]
+
+
+def test_sync_request_timeout_maps_to_memind_timeout(httpx_mock) -> None:
+ request = httpx.Request("GET", "https://api.example.test/open/v1/health")
+ httpx_mock.add_exception(httpx.ReadTimeout("timed out", request=request))
+ httpx_mock.add_exception(httpx.ReadTimeout("timed out", request=request))
+
+ with httpx.Client() as client:
+ with pytest.raises(MemindTimeoutError) as exc_info:
+ request_with_retries(
+ client,
+ "GET",
+ "https://api.example.test/open/v1/health",
+ retry_config=RetryConfig(max_retries=1, jitter=0.0),
+ sleep=lambda _delay: None,
+ )
+
+ assert "Request timed out" in str(exc_info.value)
+ assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)
+
+
+def test_sync_request_transport_error_maps_to_connection_error(httpx_mock) -> None:
+ request = httpx.Request("GET", "https://api.example.test/open/v1/health")
+ httpx_mock.add_exception(httpx.ConnectError("connection refused", request=request))
+
+ with httpx.Client() as client:
+ with pytest.raises(MemindConnectionError) as exc_info:
+ request_with_retries(
+ client,
+ "GET",
+ "https://api.example.test/open/v1/health",
+ retry_config=RetryConfig(max_retries=0),
+ )
+
+ assert "Connection failed" in str(exc_info.value)
+ assert isinstance(exc_info.value.__cause__, httpx.ConnectError)
+
+
+@pytest.mark.asyncio
+async def test_async_request_retries_503_then_returns_success(httpx_mock) -> None:
+ sleeps: list[float] = []
+
+ async def record_sleep(delay: float) -> None:
+ sleeps.append(delay)
+
+ httpx_mock.add_response(status_code=503, json={"code": "unavailable"})
+ httpx_mock.add_response(status_code=200, json={"code": "success", "data": {"ok": True}})
+
+ async with httpx.AsyncClient() as client:
+ response = await async_request_with_retries(
+ client,
+ "GET",
+ "https://api.example.test/open/v1/health",
+ retry_config=RetryConfig(max_retries=2, jitter=0.0),
+ sleep=record_sleep,
+ )
+
+ assert response.status_code == 200
+ assert sleeps == [0.5]
diff --git a/pom.xml b/pom.xml
index 7aca727f..7b69b00e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -339,6 +339,18 @@
memind-integrations/codex/**
memind-ui/**
memind-evaluation/data/**
+ memind-clients/python/.coverage
+ memind-clients/python/.mypy_cache/**
+ memind-clients/python/.pytest_cache/**
+ memind-clients/python/.ruff_cache/**
+ memind-clients/python/.uv-cache/**
+ memind-clients/python/.venv/**
+ memind-clients/python/build/**
+ memind-clients/python/dist/**
+ memind-clients/python/src/*.egg-info/**
+ memind-clients/python/uv.lock
+ memind-clients/python/**/__pycache__/**
+ memind-clients/python/**/*.pyc