thrindex 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thrindex-0.2.1/PKG-INFO +41 -0
- thrindex-0.2.1/pyproject.toml +79 -0
- thrindex-0.2.1/setup.cfg +4 -0
- thrindex-0.2.1/tests/test_async_client.py +317 -0
- thrindex-0.2.1/tests/test_client.py +226 -0
- thrindex-0.2.1/tests/test_integrations.py +577 -0
- thrindex-0.2.1/tests/test_list.py +153 -0
- thrindex-0.2.1/thrindex/__init__.py +72 -0
- thrindex-0.2.1/thrindex/async_client.py +333 -0
- thrindex-0.2.1/thrindex/client.py +352 -0
- thrindex-0.2.1/thrindex/integrations/__init__.py +17 -0
- thrindex-0.2.1/thrindex/integrations/_helpers.py +187 -0
- thrindex-0.2.1/thrindex/integrations/anthropic.py +325 -0
- thrindex-0.2.1/thrindex/integrations/langchain.py +244 -0
- thrindex-0.2.1/thrindex/integrations/llamaindex.py +203 -0
- thrindex-0.2.1/thrindex/integrations/openai.py +353 -0
- thrindex-0.2.1/thrindex/models.py +97 -0
- thrindex-0.2.1/thrindex.egg-info/PKG-INFO +41 -0
- thrindex-0.2.1/thrindex.egg-info/SOURCES.txt +20 -0
- thrindex-0.2.1/thrindex.egg-info/dependency_links.txt +1 -0
- thrindex-0.2.1/thrindex.egg-info/requires.txt +27 -0
- thrindex-0.2.1/thrindex.egg-info/top_level.txt +1 -0
thrindex-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thrindex
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: The memory OS for AI agents — persistent memory, semantic search and LLM extraction.
|
|
5
|
+
Author-email: Thrindex <hello@thrindex.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://thrindex.com
|
|
8
|
+
Project-URL: Documentation, https://docs.thrindex.com
|
|
9
|
+
Project-URL: Repository, https://github.com/thrindex/thrindex.git
|
|
10
|
+
Keywords: ai,agents,memory,llm,openai,anthropic,langchain,semantic-search,rag
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.28.0
|
|
22
|
+
Requires-Dist: pydantic>=2.10.0
|
|
23
|
+
Provides-Extra: openai
|
|
24
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
25
|
+
Provides-Extra: anthropic
|
|
26
|
+
Requires-Dist: anthropic>=0.20.0; extra == "anthropic"
|
|
27
|
+
Provides-Extra: langchain
|
|
28
|
+
Requires-Dist: langchain-core>=0.2.0; extra == "langchain"
|
|
29
|
+
Provides-Extra: llamaindex
|
|
30
|
+
Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: openai>=1.0.0; extra == "all"
|
|
33
|
+
Requires-Dist: anthropic>=0.20.0; extra == "all"
|
|
34
|
+
Requires-Dist: langchain-core>=0.2.0; extra == "all"
|
|
35
|
+
Requires-Dist: llama-index-core>=0.10.0; extra == "all"
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: ruff>=0.9.0; extra == "dev"
|
|
38
|
+
Requires-Dist: mypy>=1.13.0; extra == "dev"
|
|
39
|
+
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
40
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
41
|
+
Requires-Dist: anyio[trio]>=4.0.0; extra == "dev"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "thrindex"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "The memory OS for AI agents — persistent memory, semantic search and LLM extraction."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Thrindex", email = "hello@thrindex.com" }]
|
|
13
|
+
keywords = ["ai", "agents", "memory", "llm", "openai", "anthropic", "langchain", "semantic-search", "rag"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.28.0",
|
|
26
|
+
"pydantic>=2.10.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://thrindex.com"
|
|
31
|
+
Documentation = "https://docs.thrindex.com"
|
|
32
|
+
Repository = "https://github.com/thrindex/thrindex.git"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
openai = [
|
|
36
|
+
"openai>=1.0.0",
|
|
37
|
+
]
|
|
38
|
+
anthropic = [
|
|
39
|
+
"anthropic>=0.20.0",
|
|
40
|
+
]
|
|
41
|
+
langchain = [
|
|
42
|
+
"langchain-core>=0.2.0",
|
|
43
|
+
]
|
|
44
|
+
llamaindex = [
|
|
45
|
+
"llama-index-core>=0.10.0",
|
|
46
|
+
]
|
|
47
|
+
all = [
|
|
48
|
+
"openai>=1.0.0",
|
|
49
|
+
"anthropic>=0.20.0",
|
|
50
|
+
"langchain-core>=0.2.0",
|
|
51
|
+
"llama-index-core>=0.10.0",
|
|
52
|
+
]
|
|
53
|
+
dev = [
|
|
54
|
+
"ruff>=0.9.0",
|
|
55
|
+
"mypy>=1.13.0",
|
|
56
|
+
"pytest>=8.3.0",
|
|
57
|
+
"pytest-asyncio>=0.24.0",
|
|
58
|
+
"anyio[trio]>=4.0.0",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["."]
|
|
63
|
+
include = ["thrindex*"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
target-version = "py310"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I", "UP"]
|
|
71
|
+
ignore = []
|
|
72
|
+
|
|
73
|
+
[tool.mypy]
|
|
74
|
+
strict = true
|
|
75
|
+
python_version = "3.10"
|
|
76
|
+
|
|
77
|
+
[tool.pytest.ini_options]
|
|
78
|
+
testpaths = ["tests"]
|
|
79
|
+
asyncio_mode = "auto"
|
thrindex-0.2.1/setup.cfg
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Unit tests for the async thrindex SDK client.
|
|
2
|
+
|
|
3
|
+
Mirrors the sync client tests (test_client.py) but exercises AsyncClient.
|
|
4
|
+
Uses httpx.MockTransport via pytest-asyncio — no real network calls made.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import thrindex
|
|
17
|
+
from thrindex.async_client import AsyncThrindex
|
|
18
|
+
from thrindex.client import AuthError, NotFoundError, RateLimitError, ThrindexError
|
|
19
|
+
from thrindex.models import ListResponse, MemoryDetail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _transport(*responses: httpx.Response) -> httpx.MockTransport:
|
|
23
|
+
queue: deque[httpx.Response] = deque(responses)
|
|
24
|
+
|
|
25
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
26
|
+
if queue:
|
|
27
|
+
return queue.popleft()
|
|
28
|
+
raise httpx.RequestError("no more mock responses")
|
|
29
|
+
|
|
30
|
+
return httpx.MockTransport(handler)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _json(body: Any, status: int = 200) -> httpx.Response:
|
|
34
|
+
return httpx.Response(
|
|
35
|
+
status_code=status,
|
|
36
|
+
headers={"Content-Type": "application/json"},
|
|
37
|
+
content=json.dumps(body).encode(),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_RECORD = {
|
|
42
|
+
"id": "mem-1",
|
|
43
|
+
"org_id": "org-1",
|
|
44
|
+
"agent_id": "agent-1",
|
|
45
|
+
"user_id": "user-1",
|
|
46
|
+
"level": 0,
|
|
47
|
+
"status": "active",
|
|
48
|
+
"importance": 0.7,
|
|
49
|
+
"confidence": 0.9,
|
|
50
|
+
"source": "agent",
|
|
51
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
52
|
+
"updated_at": "2026-01-01T00:00:00Z",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# ── add() ──────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_async_add_success() -> None:
|
|
59
|
+
transport = _transport(_json({"id": "mem-123", "status": "queued"}, 202))
|
|
60
|
+
async with AsyncThrindex("mk_live_test", base_url="http://localhost", transport=transport) as client:
|
|
61
|
+
memory_id = await client.add("User likes cats", agent_id="agent-1", user_id="user-1")
|
|
62
|
+
assert memory_id == "mem-123"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_async_add_three_lines() -> None:
|
|
66
|
+
transport = _transport(_json({"id": "abc", "status": "queued"}, 202))
|
|
67
|
+
async with AsyncThrindex("mk_live_test", base_url="http://localhost", transport=transport) as client:
|
|
68
|
+
memory_id = await client.add("fact", agent_id="a", user_id="u")
|
|
69
|
+
assert isinstance(memory_id, str)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_async_add_retries_on_5xx() -> None:
|
|
73
|
+
transport = _transport(
|
|
74
|
+
httpx.Response(500, content=b"error"),
|
|
75
|
+
httpx.Response(503, content=b"unavailable"),
|
|
76
|
+
_json({"id": "mem-ok", "status": "queued"}, 202),
|
|
77
|
+
)
|
|
78
|
+
async with AsyncThrindex(
|
|
79
|
+
"mk_live_test", base_url="http://localhost", transport=transport, max_retries=3
|
|
80
|
+
) as client:
|
|
81
|
+
memory_id = await client.add("fact", agent_id="a", user_id="u")
|
|
82
|
+
assert memory_id == "mem-ok"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def test_async_add_raises_after_max_retries() -> None:
|
|
86
|
+
transport = _transport(
|
|
87
|
+
httpx.Response(500, content=b"err"),
|
|
88
|
+
httpx.Response(500, content=b"err"),
|
|
89
|
+
httpx.Response(500, content=b"err"),
|
|
90
|
+
httpx.Response(500, content=b"err"),
|
|
91
|
+
)
|
|
92
|
+
async with AsyncThrindex(
|
|
93
|
+
"mk_live_test", base_url="http://localhost", transport=transport, max_retries=2
|
|
94
|
+
) as client:
|
|
95
|
+
with pytest.raises(ThrindexError):
|
|
96
|
+
await client.add("fact", agent_id="a", user_id="u")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def test_async_add_raises_on_401() -> None:
|
|
100
|
+
body = {"error": {"code": "unauthorized", "message": "invalid api key"}}
|
|
101
|
+
async with AsyncThrindex(
|
|
102
|
+
"mk_live_bad_key", base_url="http://localhost", transport=_transport(_json(body, 401))
|
|
103
|
+
) as client:
|
|
104
|
+
with pytest.raises(AuthError):
|
|
105
|
+
await client.add("fact", agent_id="a", user_id="u")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_async_add_respects_retry_after_on_429() -> None:
|
|
109
|
+
transport = _transport(
|
|
110
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"rate limited"),
|
|
111
|
+
_json({"id": "mem-ok", "status": "queued"}, 202),
|
|
112
|
+
)
|
|
113
|
+
async with AsyncThrindex(
|
|
114
|
+
"mk_live_test", base_url="http://localhost", transport=transport, max_retries=2
|
|
115
|
+
) as client:
|
|
116
|
+
memory_id = await client.add("fact", agent_id="a", user_id="u")
|
|
117
|
+
assert memory_id == "mem-ok"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_async_add_raises_rate_limit_after_exhausted_retries() -> None:
|
|
121
|
+
transport = _transport(
|
|
122
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
123
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
124
|
+
)
|
|
125
|
+
async with AsyncThrindex(
|
|
126
|
+
"mk_live_test", base_url="http://localhost", transport=transport, max_retries=1
|
|
127
|
+
) as client:
|
|
128
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
129
|
+
await client.add("fact", agent_id="a", user_id="u")
|
|
130
|
+
assert exc_info.value.status_code == 429
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── search() ──────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def test_async_search_success() -> None:
|
|
137
|
+
body = {
|
|
138
|
+
"results": [
|
|
139
|
+
{
|
|
140
|
+
"id": "mem-1",
|
|
141
|
+
"content": "User likes cats",
|
|
142
|
+
"agent_id": "agent-1",
|
|
143
|
+
"user_id": "user-1",
|
|
144
|
+
"level": 0,
|
|
145
|
+
"status": "active",
|
|
146
|
+
"importance": 0.8,
|
|
147
|
+
"confidence": 1.0,
|
|
148
|
+
"score": 0.9,
|
|
149
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
"latency_ms": 12,
|
|
153
|
+
"cache_hit": True,
|
|
154
|
+
}
|
|
155
|
+
async with AsyncThrindex(
|
|
156
|
+
"mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
|
|
157
|
+
) as client:
|
|
158
|
+
results = await client.search("cat preferences", agent_id="agent-1", user_id="user-1")
|
|
159
|
+
assert len(results) == 1
|
|
160
|
+
assert results[0].id == "mem-1"
|
|
161
|
+
assert results[0].score == pytest.approx(0.9)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_async_search_empty() -> None:
|
|
165
|
+
body = {"results": [], "latency_ms": 5, "cache_hit": False}
|
|
166
|
+
async with AsyncThrindex(
|
|
167
|
+
"mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
|
|
168
|
+
) as client:
|
|
169
|
+
results = await client.search("query", agent_id="a", user_id="u")
|
|
170
|
+
assert results == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── list() ────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def test_async_list_success() -> None:
|
|
177
|
+
body = {"results": [_RECORD], "next_cursor": "tok-xyz"}
|
|
178
|
+
async with AsyncThrindex(
|
|
179
|
+
"mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
|
|
180
|
+
) as client:
|
|
181
|
+
resp = await client.list(agent_id="agent-1", user_id="user-1")
|
|
182
|
+
assert isinstance(resp, ListResponse)
|
|
183
|
+
assert resp.results[0].id == "mem-1"
|
|
184
|
+
assert resp.next_cursor == "tok-xyz"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def test_async_list_empty() -> None:
|
|
188
|
+
body = {"results": [], "next_cursor": None}
|
|
189
|
+
async with AsyncThrindex(
|
|
190
|
+
"mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
|
|
191
|
+
) as client:
|
|
192
|
+
resp = await client.list()
|
|
193
|
+
assert resp.results == []
|
|
194
|
+
assert resp.next_cursor is None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── batch_add() ───────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def test_async_batch_add() -> None:
|
|
201
|
+
from collections import deque as _deque
|
|
202
|
+
|
|
203
|
+
transport = _transport(
|
|
204
|
+
_json({"id": "id-1", "status": "queued"}, 202),
|
|
205
|
+
_json({"id": "id-2", "status": "queued"}, 202),
|
|
206
|
+
_json({"id": "id-3", "status": "queued"}, 202),
|
|
207
|
+
)
|
|
208
|
+
async with AsyncThrindex(
|
|
209
|
+
"mk_live_test", base_url="http://localhost", transport=transport
|
|
210
|
+
) as client:
|
|
211
|
+
ids = await client.batch_add(
|
|
212
|
+
[{"content": "a"}, {"content": "b"}, {"content": "c"}],
|
|
213
|
+
agent_id="agent-1",
|
|
214
|
+
user_id="user-1",
|
|
215
|
+
)
|
|
216
|
+
assert ids == ["id-1", "id-2", "id-3"]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── get() ─────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def test_async_get_returns_memory_detail() -> None:
|
|
223
|
+
body = {**_RECORD, "content": "User prefers dark mode"}
|
|
224
|
+
async with AsyncThrindex(
|
|
225
|
+
"mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
|
|
226
|
+
) as client:
|
|
227
|
+
detail = await client.get("mem-1")
|
|
228
|
+
assert isinstance(detail, MemoryDetail)
|
|
229
|
+
assert detail.content == "User prefers dark mode"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def test_async_get_raises_not_found() -> None:
|
|
233
|
+
body = {"error": {"code": "not_found", "message": "memory not found"}}
|
|
234
|
+
async with AsyncThrindex(
|
|
235
|
+
"mk_live_test", base_url="http://localhost", transport=_transport(_json(body, 404))
|
|
236
|
+
) as client:
|
|
237
|
+
with pytest.raises(NotFoundError):
|
|
238
|
+
await client.get("nonexistent")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── delete() ──────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def test_async_delete_soft() -> None:
|
|
245
|
+
async with AsyncThrindex(
|
|
246
|
+
"mk_live_test",
|
|
247
|
+
base_url="http://localhost",
|
|
248
|
+
transport=_transport(httpx.Response(204)),
|
|
249
|
+
) as client:
|
|
250
|
+
await client.delete("mem-1") # must not raise
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def test_async_delete_hard() -> None:
|
|
254
|
+
async with AsyncThrindex(
|
|
255
|
+
"mk_live_test",
|
|
256
|
+
base_url="http://localhost",
|
|
257
|
+
transport=_transport(httpx.Response(204)),
|
|
258
|
+
) as client:
|
|
259
|
+
await client.delete("mem-1", hard=True)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def test_async_delete_not_found() -> None:
|
|
263
|
+
body = {"error": {"code": "not_found", "message": "memory not found"}}
|
|
264
|
+
async with AsyncThrindex(
|
|
265
|
+
"mk_live_test",
|
|
266
|
+
base_url="http://localhost",
|
|
267
|
+
transport=_transport(_json(body, 404)),
|
|
268
|
+
) as client:
|
|
269
|
+
with pytest.raises(NotFoundError):
|
|
270
|
+
await client.delete("nonexistent")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def test_async_delete_raises_rate_limit_after_exhausted_retries() -> None:
|
|
274
|
+
transport = _transport(
|
|
275
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
276
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
277
|
+
)
|
|
278
|
+
async with AsyncThrindex(
|
|
279
|
+
"mk_live_test", base_url="http://localhost", transport=transport, max_retries=1
|
|
280
|
+
) as client:
|
|
281
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
282
|
+
await client.delete("mem-1")
|
|
283
|
+
assert exc_info.value.status_code == 429
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def test_async_add_401_message_forwarded() -> None:
|
|
287
|
+
body = {"error": {"code": "forbidden", "message": "missing scope: memory:write"}}
|
|
288
|
+
async with AsyncThrindex(
|
|
289
|
+
"mk_live_readonly_key",
|
|
290
|
+
base_url="http://localhost",
|
|
291
|
+
transport=_transport(_json(body, 401)),
|
|
292
|
+
) as client:
|
|
293
|
+
with pytest.raises(AuthError) as exc_info:
|
|
294
|
+
await client.add("fact", agent_id="a", user_id="u")
|
|
295
|
+
assert "missing scope" in str(exc_info.value)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ── context manager ───────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def test_async_context_manager() -> None:
|
|
302
|
+
transport = _transport(_json({"id": "x", "status": "queued"}, 202))
|
|
303
|
+
async with AsyncThrindex("mk_live_test", base_url="http://localhost", transport=transport) as c:
|
|
304
|
+
mid = await c.add("test", agent_id="a", user_id="u")
|
|
305
|
+
assert mid == "x"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── validation ────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_async_empty_api_key_raises() -> None:
|
|
312
|
+
with pytest.raises(ValueError, match="api_key"):
|
|
313
|
+
AsyncThrindex("")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_async_client_exported_from_top_level() -> None:
|
|
317
|
+
assert thrindex.AsyncThrindex is AsyncThrindex
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Unit tests for the thrindex SDK client.
|
|
2
|
+
|
|
3
|
+
Uses httpx.MockTransport so no real network calls are made.
|
|
4
|
+
Tests: happy path, retry logic, 429 handling, 401/404 errors, batch_add.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import thrindex
|
|
17
|
+
from thrindex.client import Thrindex, RateLimitError, ThrindexError, AuthError, NotFoundError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _transport(*responses: httpx.Response) -> httpx.MockTransport:
|
|
21
|
+
"""Return a MockTransport that replays the given response sequence."""
|
|
22
|
+
queue: deque[httpx.Response] = deque(responses)
|
|
23
|
+
|
|
24
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
25
|
+
if queue:
|
|
26
|
+
return queue.popleft()
|
|
27
|
+
raise httpx.RequestError("no more responses") # test misconfiguration
|
|
28
|
+
|
|
29
|
+
return httpx.MockTransport(handler)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _json_response(body: Any, status: int = 200) -> httpx.Response:
|
|
33
|
+
return httpx.Response(
|
|
34
|
+
status_code=status,
|
|
35
|
+
headers={"Content-Type": "application/json"},
|
|
36
|
+
content=json.dumps(body).encode(),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── add() ──────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def test_add_success() -> None:
|
|
43
|
+
transport = _transport(_json_response({"id": "mem-123", "status": "queued"}, 202))
|
|
44
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
45
|
+
memory_id = client.add("User likes cats", agent_id="agent-1", user_id="user-1")
|
|
46
|
+
assert memory_id == "mem-123"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_add_three_lines() -> None:
|
|
50
|
+
"""Acceptance criterion: SDK works in three lines."""
|
|
51
|
+
transport = _transport(_json_response({"id": "abc", "status": "queued"}, 202))
|
|
52
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
53
|
+
memory_id = client.add("fact", agent_id="a", user_id="u")
|
|
54
|
+
assert isinstance(memory_id, str)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_add_retries_on_5xx() -> None:
|
|
58
|
+
transport = _transport(
|
|
59
|
+
httpx.Response(500, content=b"error"),
|
|
60
|
+
httpx.Response(503, content=b"unavailable"),
|
|
61
|
+
_json_response({"id": "mem-ok", "status": "queued"}, 202),
|
|
62
|
+
)
|
|
63
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=3)
|
|
64
|
+
memory_id = client.add("fact", agent_id="a", user_id="u")
|
|
65
|
+
assert memory_id == "mem-ok"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_add_raises_after_max_retries() -> None:
|
|
69
|
+
transport = _transport(
|
|
70
|
+
httpx.Response(500, content=b"err"),
|
|
71
|
+
httpx.Response(500, content=b"err"),
|
|
72
|
+
httpx.Response(500, content=b"err"),
|
|
73
|
+
httpx.Response(500, content=b"err"),
|
|
74
|
+
)
|
|
75
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=2)
|
|
76
|
+
with pytest.raises(ThrindexError):
|
|
77
|
+
client.add("fact", agent_id="a", user_id="u")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_add_raises_on_401() -> None:
|
|
81
|
+
body = {"error": {"code": "unauthorized", "message": "invalid api key"}}
|
|
82
|
+
transport = _transport(_json_response(body, 401))
|
|
83
|
+
client = Thrindex("mk_live_bad_key", base_url="http://localhost", transport=transport)
|
|
84
|
+
with pytest.raises(AuthError):
|
|
85
|
+
client.add("fact", agent_id="a", user_id="u")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_add_respects_retry_after_on_429() -> None:
|
|
89
|
+
transport = _transport(
|
|
90
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"rate limited"),
|
|
91
|
+
_json_response({"id": "mem-ok", "status": "queued"}, 202),
|
|
92
|
+
)
|
|
93
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=2)
|
|
94
|
+
memory_id = client.add("fact", agent_id="a", user_id="u")
|
|
95
|
+
assert memory_id == "mem-ok"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_add_raises_rate_limit_after_exhausted_retries() -> None:
|
|
99
|
+
transport = _transport(
|
|
100
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
101
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
102
|
+
)
|
|
103
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=1)
|
|
104
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
105
|
+
client.add("fact", agent_id="a", user_id="u")
|
|
106
|
+
assert exc_info.value.status_code == 429
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── search() ──────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def test_search_success() -> None:
|
|
112
|
+
body = {
|
|
113
|
+
"results": [
|
|
114
|
+
{
|
|
115
|
+
"id": "mem-1",
|
|
116
|
+
"content": "User likes cats",
|
|
117
|
+
"agent_id": "agent-1",
|
|
118
|
+
"user_id": "user-1",
|
|
119
|
+
"level": 0,
|
|
120
|
+
"status": "active",
|
|
121
|
+
"importance": 0.8,
|
|
122
|
+
"confidence": 1.0,
|
|
123
|
+
"score": 0.9,
|
|
124
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"latency_ms": 12,
|
|
128
|
+
"cache_hit": True,
|
|
129
|
+
}
|
|
130
|
+
transport = _transport(_json_response(body))
|
|
131
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
132
|
+
results = client.search("cat preferences", agent_id="agent-1", user_id="user-1")
|
|
133
|
+
assert len(results) == 1
|
|
134
|
+
assert results[0].id == "mem-1"
|
|
135
|
+
assert results[0].score == pytest.approx(0.9)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_search_three_lines() -> None:
|
|
139
|
+
"""Acceptance criterion: search works in three lines."""
|
|
140
|
+
body = {"results": [], "latency_ms": 5, "cache_hit": False}
|
|
141
|
+
transport = _transport(_json_response(body))
|
|
142
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
143
|
+
results = client.search("query", agent_id="a", user_id="u")
|
|
144
|
+
assert isinstance(results, list)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── batch_add() ───────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def test_batch_add() -> None:
|
|
150
|
+
transport = _transport(
|
|
151
|
+
_json_response({"id": "id-1", "status": "queued"}, 202),
|
|
152
|
+
_json_response({"id": "id-2", "status": "queued"}, 202),
|
|
153
|
+
_json_response({"id": "id-3", "status": "queued"}, 202),
|
|
154
|
+
)
|
|
155
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
156
|
+
ids = client.batch_add(
|
|
157
|
+
[{"content": "a"}, {"content": "b"}, {"content": "c"}],
|
|
158
|
+
agent_id="agent-1",
|
|
159
|
+
user_id="user-1",
|
|
160
|
+
)
|
|
161
|
+
assert ids == ["id-1", "id-2", "id-3"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── delete() ──────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def test_delete_soft() -> None:
|
|
167
|
+
transport = _transport(httpx.Response(204))
|
|
168
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
169
|
+
client.delete("mem-1") # should not raise
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_delete_hard() -> None:
|
|
173
|
+
transport = _transport(httpx.Response(204))
|
|
174
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
175
|
+
client.delete("mem-1", hard=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_delete_not_found() -> None:
|
|
179
|
+
body = {"error": {"code": "not_found", "message": "memory not found"}}
|
|
180
|
+
transport = _transport(_json_response(body, 404))
|
|
181
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
|
|
182
|
+
with pytest.raises(NotFoundError):
|
|
183
|
+
client.delete("nonexistent")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_delete_raises_rate_limit_after_exhausted_retries() -> None:
|
|
187
|
+
transport = _transport(
|
|
188
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
189
|
+
httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
|
|
190
|
+
)
|
|
191
|
+
client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=1)
|
|
192
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
193
|
+
client.delete("mem-1")
|
|
194
|
+
assert exc_info.value.status_code == 429
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_add_401_message_forwarded() -> None:
|
|
198
|
+
body = {"error": {"code": "forbidden", "message": "missing scope: memory:write"}}
|
|
199
|
+
transport = _transport(_json_response(body, 401))
|
|
200
|
+
client = Thrindex("mk_live_readonly_key", base_url="http://localhost", transport=transport)
|
|
201
|
+
with pytest.raises(AuthError) as exc_info:
|
|
202
|
+
client.add("fact", agent_id="a", user_id="u")
|
|
203
|
+
assert "missing scope" in str(exc_info.value)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── context manager ───────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def test_context_manager() -> None:
|
|
209
|
+
transport = _transport(_json_response({"id": "x", "status": "queued"}, 202))
|
|
210
|
+
with Thrindex("mk_live_test", base_url="http://localhost", transport=transport) as c:
|
|
211
|
+
mid = c.add("test", agent_id="a", user_id="u")
|
|
212
|
+
assert mid == "x"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── validation ────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def test_empty_api_key_raises() -> None:
|
|
218
|
+
with pytest.raises(ValueError, match="api_key"):
|
|
219
|
+
Thrindex("")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_imports() -> None:
|
|
223
|
+
"""Public surface: Client and error types importable from top-level."""
|
|
224
|
+
assert thrindex.Thrindex is Thrindex
|
|
225
|
+
assert thrindex.ThrindexError is ThrindexError
|
|
226
|
+
assert thrindex.RateLimitError is RateLimitError
|