syntarus 0.2.0__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.
- syntarus-0.2.0/LICENSE +21 -0
- syntarus-0.2.0/PKG-INFO +142 -0
- syntarus-0.2.0/README.md +95 -0
- syntarus-0.2.0/examples/crewai_agent.py +44 -0
- syntarus-0.2.0/examples/custom_agent.py +42 -0
- syntarus-0.2.0/examples/enterprise_load.py +64 -0
- syntarus-0.2.0/examples/enterprise_smoke.py +78 -0
- syntarus-0.2.0/examples/langchain_agent.py +38 -0
- syntarus-0.2.0/examples/webhook_verify.py +23 -0
- syntarus-0.2.0/pyproject.toml +37 -0
- syntarus-0.2.0/src/syntarus/__init__.py +18 -0
- syntarus-0.2.0/src/syntarus/client.py +219 -0
- syntarus-0.2.0/src/syntarus/exceptions.py +38 -0
- syntarus-0.2.0/tests/test_client.py +145 -0
- syntarus-0.2.0/tests/test_webhook_verify.py +19 -0
syntarus-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syntarus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
syntarus-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: syntarus
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Project-scoped memory client for Syntarus
|
|
5
|
+
Project-URL: Documentation, https://www.syntarus.com/pages/developers.html
|
|
6
|
+
Project-URL: Homepage, https://www.syntarus.com
|
|
7
|
+
Project-URL: Source, https://github.com/sujalkherawat25-stack/memoryos/tree/main/sdk
|
|
8
|
+
Author: Syntarus
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Syntarus
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: agents,ai,llm,memory
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Requires-Dist: httpx<1,>=0.27
|
|
41
|
+
Provides-Extra: test
|
|
42
|
+
Requires-Dist: build<2,>=1.2; extra == 'test'
|
|
43
|
+
Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'test'
|
|
44
|
+
Requires-Dist: pytest<9,>=8; extra == 'test'
|
|
45
|
+
Requires-Dist: twine<7,>=5; extra == 'test'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# Syntarus Python SDK
|
|
49
|
+
|
|
50
|
+
The official Python client for the project-scoped Syntarus memory API.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install syntarus
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Python 3.10 or newer is required. Keep project API keys in a server-side
|
|
59
|
+
secret manager; never ship them in browser or mobile applications.
|
|
60
|
+
|
|
61
|
+
## Add and search memory
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from syntarus import MemoryClient
|
|
65
|
+
|
|
66
|
+
with MemoryClient(api_key="sk_mem_...") as memory:
|
|
67
|
+
accepted = memory.add(
|
|
68
|
+
user_id="customer_123",
|
|
69
|
+
agent_id="support_agent",
|
|
70
|
+
run_id="ticket_456",
|
|
71
|
+
messages=[
|
|
72
|
+
{"role": "user", "content": "I prefer vegetarian food."},
|
|
73
|
+
{"role": "assistant", "content": "I will remember that."},
|
|
74
|
+
],
|
|
75
|
+
metadata={"channel": "voice"},
|
|
76
|
+
idempotency_key="call-456-final",
|
|
77
|
+
)
|
|
78
|
+
memory.wait_for_event(accepted["event_id"])
|
|
79
|
+
result = memory.search(
|
|
80
|
+
"What food does the customer prefer?",
|
|
81
|
+
user_id="customer_123",
|
|
82
|
+
agent_id="support_agent",
|
|
83
|
+
)
|
|
84
|
+
print(result["context"])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`agent_id` creates an isolated memory namespace. Omit it for a user profile
|
|
88
|
+
shared by every agent in the project. `run_id` groups the conversation history
|
|
89
|
+
used during ingestion. `metadata` is event metadata and is returned by event
|
|
90
|
+
status and webhook payloads; it is not currently a memory-search filter.
|
|
91
|
+
|
|
92
|
+
## Async client
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from syntarus import AsyncMemoryClient
|
|
96
|
+
|
|
97
|
+
async with AsyncMemoryClient(api_key="sk_mem_...") as memory:
|
|
98
|
+
accepted = await memory.add(
|
|
99
|
+
user_id="customer_123",
|
|
100
|
+
messages=[{"role": "user", "content": "Call me after 5 PM."}],
|
|
101
|
+
)
|
|
102
|
+
await memory.wait_for_event(accepted["event_id"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Lifecycle operations
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
memory.export(user_id="customer_123", agent_id="support_agent")
|
|
109
|
+
memory.delete("memory-uuid")
|
|
110
|
+
memory.delete_user(user_id="customer_123", agent_id="support_agent")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Deleting a user without `agent_id` deletes only the shared user namespace.
|
|
114
|
+
Delete each agent namespace separately when an application uses agent-scoped
|
|
115
|
+
memory.
|
|
116
|
+
|
|
117
|
+
## Webhooks
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
created = memory.create_webhook("https://example.com/syntarus/events")
|
|
121
|
+
signing_secret = created["signing_secret"] # returned once
|
|
122
|
+
memory.list_webhooks()
|
|
123
|
+
memory.webhook_deliveries(webhook_id=created["webhook"]["id"])
|
|
124
|
+
memory.delete_webhook(created["webhook"]["id"])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Deliveries include `X-Syntarus-Timestamp`, `X-Syntarus-Delivery`, and
|
|
128
|
+
`X-Syntarus-Signature-256`. Verify the signature as HMAC-SHA256 over
|
|
129
|
+
`<timestamp>.<raw_request_body>` and reject timestamps outside a five-minute
|
|
130
|
+
window. A non-2xx response is retried durably and eventually becomes a
|
|
131
|
+
dead-letter delivery visible through `webhook_deliveries`.
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
The SDK raises typed exceptions derived from `SyntarusError`:
|
|
136
|
+
|
|
137
|
+
- `AuthenticationError`
|
|
138
|
+
- `PermissionDenied`
|
|
139
|
+
- `RateLimitError` (`retry_after` is available when the API sends it)
|
|
140
|
+
- `APIConnectionError`
|
|
141
|
+
- `APITimeoutError`
|
|
142
|
+
- `EventFailedError` (`event` contains the terminal event record)
|
syntarus-0.2.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Syntarus Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python client for the project-scoped Syntarus memory API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install syntarus
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.10 or newer is required. Keep project API keys in a server-side
|
|
12
|
+
secret manager; never ship them in browser or mobile applications.
|
|
13
|
+
|
|
14
|
+
## Add and search memory
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from syntarus import MemoryClient
|
|
18
|
+
|
|
19
|
+
with MemoryClient(api_key="sk_mem_...") as memory:
|
|
20
|
+
accepted = memory.add(
|
|
21
|
+
user_id="customer_123",
|
|
22
|
+
agent_id="support_agent",
|
|
23
|
+
run_id="ticket_456",
|
|
24
|
+
messages=[
|
|
25
|
+
{"role": "user", "content": "I prefer vegetarian food."},
|
|
26
|
+
{"role": "assistant", "content": "I will remember that."},
|
|
27
|
+
],
|
|
28
|
+
metadata={"channel": "voice"},
|
|
29
|
+
idempotency_key="call-456-final",
|
|
30
|
+
)
|
|
31
|
+
memory.wait_for_event(accepted["event_id"])
|
|
32
|
+
result = memory.search(
|
|
33
|
+
"What food does the customer prefer?",
|
|
34
|
+
user_id="customer_123",
|
|
35
|
+
agent_id="support_agent",
|
|
36
|
+
)
|
|
37
|
+
print(result["context"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`agent_id` creates an isolated memory namespace. Omit it for a user profile
|
|
41
|
+
shared by every agent in the project. `run_id` groups the conversation history
|
|
42
|
+
used during ingestion. `metadata` is event metadata and is returned by event
|
|
43
|
+
status and webhook payloads; it is not currently a memory-search filter.
|
|
44
|
+
|
|
45
|
+
## Async client
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from syntarus import AsyncMemoryClient
|
|
49
|
+
|
|
50
|
+
async with AsyncMemoryClient(api_key="sk_mem_...") as memory:
|
|
51
|
+
accepted = await memory.add(
|
|
52
|
+
user_id="customer_123",
|
|
53
|
+
messages=[{"role": "user", "content": "Call me after 5 PM."}],
|
|
54
|
+
)
|
|
55
|
+
await memory.wait_for_event(accepted["event_id"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Lifecycle operations
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
memory.export(user_id="customer_123", agent_id="support_agent")
|
|
62
|
+
memory.delete("memory-uuid")
|
|
63
|
+
memory.delete_user(user_id="customer_123", agent_id="support_agent")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Deleting a user without `agent_id` deletes only the shared user namespace.
|
|
67
|
+
Delete each agent namespace separately when an application uses agent-scoped
|
|
68
|
+
memory.
|
|
69
|
+
|
|
70
|
+
## Webhooks
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
created = memory.create_webhook("https://example.com/syntarus/events")
|
|
74
|
+
signing_secret = created["signing_secret"] # returned once
|
|
75
|
+
memory.list_webhooks()
|
|
76
|
+
memory.webhook_deliveries(webhook_id=created["webhook"]["id"])
|
|
77
|
+
memory.delete_webhook(created["webhook"]["id"])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Deliveries include `X-Syntarus-Timestamp`, `X-Syntarus-Delivery`, and
|
|
81
|
+
`X-Syntarus-Signature-256`. Verify the signature as HMAC-SHA256 over
|
|
82
|
+
`<timestamp>.<raw_request_body>` and reject timestamps outside a five-minute
|
|
83
|
+
window. A non-2xx response is retried durably and eventually becomes a
|
|
84
|
+
dead-letter delivery visible through `webhook_deliveries`.
|
|
85
|
+
|
|
86
|
+
## Errors
|
|
87
|
+
|
|
88
|
+
The SDK raises typed exceptions derived from `SyntarusError`:
|
|
89
|
+
|
|
90
|
+
- `AuthenticationError`
|
|
91
|
+
- `PermissionDenied`
|
|
92
|
+
- `RateLimitError` (`retry_after` is available when the API sends it)
|
|
93
|
+
- `APIConnectionError`
|
|
94
|
+
- `APITimeoutError`
|
|
95
|
+
- `EventFailedError` (`event` contains the terminal event record)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example 3 — CrewAI agent with MemoryOS memory tool.
|
|
3
|
+
Exposes memory search as a CrewAI Tool so the agent decides when to query.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from crewai import Agent, Task, Crew
|
|
7
|
+
from crewai.tools import tool
|
|
8
|
+
from memoryos_client import MemoryOS
|
|
9
|
+
|
|
10
|
+
mem = MemoryOS(api_key="mk_crewai", auto_approve=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@tool("Search user memory")
|
|
14
|
+
def search_memory(query: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Search the user's long-term memory for facts relevant to the query.
|
|
17
|
+
Use this before answering personal questions about the user.
|
|
18
|
+
Returns a context string with relevant facts.
|
|
19
|
+
"""
|
|
20
|
+
results = mem.search(query, top_k=5)
|
|
21
|
+
if not results:
|
|
22
|
+
return "No relevant memories found."
|
|
23
|
+
return "\n".join(f"- {r.text}" for r in results)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
personal_assistant = Agent(
|
|
27
|
+
role="Personal Assistant",
|
|
28
|
+
goal="Help the user based on their preferences and history",
|
|
29
|
+
backstory="You have access to the user's long-term memory.",
|
|
30
|
+
tools=[search_memory],
|
|
31
|
+
verbose=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
task = Task(
|
|
35
|
+
description="Answer: What kind of projects does the user work on?",
|
|
36
|
+
agent=personal_assistant,
|
|
37
|
+
expected_output="A personalized answer based on memory.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
crew = Crew(agents=[personal_assistant], tasks=[task])
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
result = crew.kickoff()
|
|
44
|
+
print(result)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example 1 — Custom agent with MemoryOS memory layer.
|
|
3
|
+
Works with any LLM — Groq, OpenAI, Anthropic, Ollama.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from groq import Groq
|
|
7
|
+
from memoryos_client import MemoryOS
|
|
8
|
+
|
|
9
|
+
# Your MemoryOS API key (set in backend/.env MEMORYOS_API_KEYS)
|
|
10
|
+
mem = MemoryOS(api_key="mk_myagent", auto_approve=False)
|
|
11
|
+
client = Groq() # uses GROQ_API_KEY env var
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def chat(user_message: str, conversation_id: str = "default") -> str:
|
|
15
|
+
# 1. Retrieve relevant memories BEFORE calling LLM
|
|
16
|
+
memory_context = mem.get_context(user_message)
|
|
17
|
+
|
|
18
|
+
# 2. Build system prompt with memory injected
|
|
19
|
+
system = "You are a helpful personal assistant."
|
|
20
|
+
if memory_context:
|
|
21
|
+
system += f"\n\nWhat you know about this user:\n{memory_context}"
|
|
22
|
+
|
|
23
|
+
# 3. Call LLM
|
|
24
|
+
response = client.chat.completions.create(
|
|
25
|
+
model="openai/gpt-oss-120b",
|
|
26
|
+
messages=[
|
|
27
|
+
{"role": "system", "content": system},
|
|
28
|
+
{"role": "user", "content": user_message},
|
|
29
|
+
],
|
|
30
|
+
max_tokens=1024,
|
|
31
|
+
)
|
|
32
|
+
assistant_message = response.choices[0].message.content or ""
|
|
33
|
+
|
|
34
|
+
# 4. Ingest the turn AFTER responding
|
|
35
|
+
mem.ingest(user_message, assistant_message, conversation_id=conversation_id)
|
|
36
|
+
|
|
37
|
+
return assistant_message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
print(chat("My name is Sujal and I prefer dark mode in all tools."))
|
|
42
|
+
print(chat("What do you know about me?"))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Rate-limit-aware enterprise ingestion load test.
|
|
2
|
+
|
|
3
|
+
This intentionally counts HTTP 429 separately from accepted events. A 429 is
|
|
4
|
+
not treated as a data-integrity failure; the request is retried with bounded
|
|
5
|
+
exponential backoff when ``--retry-429`` is enabled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = argparse.ArgumentParser()
|
|
20
|
+
parser.add_argument("--events", type=int, default=100)
|
|
21
|
+
parser.add_argument("--retry-429", action="store_true")
|
|
22
|
+
parser.add_argument("--max-retries", type=int, default=5)
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
key = os.environ.get("SYNTARUS_PROJECT_A_KEY")
|
|
25
|
+
if not key:
|
|
26
|
+
raise SystemExit("Set SYNTARUS_PROJECT_A_KEY first.")
|
|
27
|
+
base = os.environ.get("SYNTARUS_BASE_URL", "https://ai.syntarus.com/v1")
|
|
28
|
+
accepted = rate_limited = other = 0
|
|
29
|
+
started = time.perf_counter()
|
|
30
|
+
|
|
31
|
+
with httpx.Client(base_url=base, headers={"Authorization": f"Bearer {key}"}, timeout=30) as client:
|
|
32
|
+
for index in range(args.events):
|
|
33
|
+
payload = {
|
|
34
|
+
"user_id": f"load_{uuid.uuid4().hex[:12]}",
|
|
35
|
+
"messages": [{
|
|
36
|
+
"role": "user",
|
|
37
|
+
"content": f"My durable enterprise load fact {index} is stable; remember this.",
|
|
38
|
+
}],
|
|
39
|
+
}
|
|
40
|
+
for attempt in range(args.max_retries + 1):
|
|
41
|
+
response = client.post("/memories", json=payload)
|
|
42
|
+
if response.status_code != 429 or not args.retry_429:
|
|
43
|
+
break
|
|
44
|
+
retry_after = response.headers.get("Retry-After")
|
|
45
|
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else min(2 ** attempt, 30)
|
|
46
|
+
time.sleep(delay)
|
|
47
|
+
if response.status_code == 202:
|
|
48
|
+
accepted += 1
|
|
49
|
+
elif response.status_code == 429:
|
|
50
|
+
rate_limited += 1
|
|
51
|
+
else:
|
|
52
|
+
other += 1
|
|
53
|
+
|
|
54
|
+
print({
|
|
55
|
+
"events": args.events,
|
|
56
|
+
"accepted": accepted,
|
|
57
|
+
"rate_limited": rate_limited,
|
|
58
|
+
"other": other,
|
|
59
|
+
"elapsed_seconds": round(time.perf_counter() - started, 2),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Live two-project isolation smoke test for a staging Syntarus deployment.
|
|
2
|
+
|
|
3
|
+
Required environment variables:
|
|
4
|
+
SYNTARUS_PROJECT_A_KEY
|
|
5
|
+
SYNTARUS_PROJECT_B_KEY
|
|
6
|
+
|
|
7
|
+
Optional:
|
|
8
|
+
SYNTARUS_BASE_URL (defaults to https://ai.syntarus.com/v1)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from syntarus import MemoryClient, SyntarusError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def required(name: str) -> str:
|
|
20
|
+
value = os.environ.get(name)
|
|
21
|
+
if not value:
|
|
22
|
+
raise SystemExit(f"Missing required environment variable: {name}")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
base_url = os.environ.get("SYNTARUS_BASE_URL", "https://ai.syntarus.com/v1")
|
|
28
|
+
key_a = required("SYNTARUS_PROJECT_A_KEY")
|
|
29
|
+
key_b = required("SYNTARUS_PROJECT_B_KEY")
|
|
30
|
+
marker = uuid.uuid4().hex
|
|
31
|
+
user_id = f"smoke_{marker[:12]}"
|
|
32
|
+
fact = (
|
|
33
|
+
f"My permanent enterprise isolation preference is marker {marker}. "
|
|
34
|
+
"Please remember this fact for future conversations."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
with (
|
|
38
|
+
MemoryClient(key_a, base_url=base_url) as project_a,
|
|
39
|
+
MemoryClient(key_b, base_url=base_url) as project_b,
|
|
40
|
+
):
|
|
41
|
+
accepted = project_a.add(
|
|
42
|
+
user_id=user_id,
|
|
43
|
+
agent_id="smoke_agent",
|
|
44
|
+
run_id=f"run_{marker[:12]}",
|
|
45
|
+
messages=[{"role": "user", "content": fact}],
|
|
46
|
+
metadata={"test": "enterprise-smoke"},
|
|
47
|
+
idempotency_key=f"smoke-{marker}",
|
|
48
|
+
)
|
|
49
|
+
project_a.wait_for_event(accepted["event_id"], timeout=120)
|
|
50
|
+
|
|
51
|
+
own = project_a.search(
|
|
52
|
+
"What is the enterprise isolation marker?",
|
|
53
|
+
user_id=user_id,
|
|
54
|
+
agent_id="smoke_agent",
|
|
55
|
+
)
|
|
56
|
+
if marker not in own.get("context", ""):
|
|
57
|
+
raise SystemExit("FAIL: Project A could not retrieve its own marker.")
|
|
58
|
+
|
|
59
|
+
other = project_b.search(
|
|
60
|
+
"What is the enterprise isolation marker?",
|
|
61
|
+
user_id=user_id,
|
|
62
|
+
agent_id="smoke_agent",
|
|
63
|
+
)
|
|
64
|
+
if marker in other.get("context", ""):
|
|
65
|
+
raise SystemExit("FAIL: Project B retrieved Project A memory.")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
project_b.event(accepted["event_id"])
|
|
69
|
+
except SyntarusError:
|
|
70
|
+
pass
|
|
71
|
+
else:
|
|
72
|
+
raise SystemExit("FAIL: Project B inspected Project A event.")
|
|
73
|
+
|
|
74
|
+
print("PASS: ingestion, polling, retrieval, and cross-project isolation")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example 2 — LangChain agent with MemoryOS as external memory.
|
|
3
|
+
Replaces LangChain's built-in ConversationBufferMemory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from langchain_groq import ChatGroq
|
|
7
|
+
from langchain.schema import HumanMessage, SystemMessage
|
|
8
|
+
from memoryos_client import MemoryOS
|
|
9
|
+
|
|
10
|
+
mem = MemoryOS(api_key="mk_langchain", auto_approve=True)
|
|
11
|
+
llm = ChatGroq(model="openai/gpt-oss-120b")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_agent(user_message: str, conversation_id: str = "lc_session") -> str:
|
|
15
|
+
# Pull memory context
|
|
16
|
+
memory_context = mem.get_context(user_message)
|
|
17
|
+
|
|
18
|
+
system_content = "You are a helpful LangChain agent with long-term memory."
|
|
19
|
+
if memory_context:
|
|
20
|
+
system_content += f"\n\nUser memory:\n{memory_context}"
|
|
21
|
+
|
|
22
|
+
messages = [
|
|
23
|
+
SystemMessage(content=system_content),
|
|
24
|
+
HumanMessage(content=user_message),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
response = llm.invoke(messages)
|
|
28
|
+
assistant_text = response.content
|
|
29
|
+
|
|
30
|
+
# Write to memory (auto_approve=True so no UI review needed for bots)
|
|
31
|
+
mem.ingest(user_message, assistant_text, conversation_id=conversation_id)
|
|
32
|
+
|
|
33
|
+
return assistant_text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
print(run_agent("I'm building a SaaS product targeting developers"))
|
|
38
|
+
print(run_agent("What should I focus on for my product?"))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Verify Syntarus webhook signatures from a raw body.
|
|
2
|
+
|
|
3
|
+
Receivers must pass the exact bytes received over the network. This helper is
|
|
4
|
+
deliberately framework-neutral so it can be used by Flask, FastAPI, Django,
|
|
5
|
+
or a serverless handler without forwarding customer payloads elsewhere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_signature(*, secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
|
|
15
|
+
expected = "sha256=" + hmac.new(
|
|
16
|
+
secret.encode("utf-8"),
|
|
17
|
+
timestamp.encode("ascii") + b"." + raw_body,
|
|
18
|
+
hashlib.sha256,
|
|
19
|
+
).hexdigest()
|
|
20
|
+
return hmac.compare_digest(expected, signature)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["verify_signature"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "syntarus"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Project-scoped memory client for Syntarus"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Syntarus" }]
|
|
13
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
14
|
+
keywords = ["ai", "agents", "memory", "llm"]
|
|
15
|
+
classifiers = ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
test = ["pytest>=8,<9", "pytest-asyncio>=0.24,<1", "build>=1.2,<2", "twine>=5,<7"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Documentation = "https://www.syntarus.com/pages/developers.html"
|
|
22
|
+
Homepage = "https://www.syntarus.com"
|
|
23
|
+
Source = "https://github.com/sujalkherawat25-stack/memoryos/tree/main/sdk"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/syntarus"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
include = [
|
|
30
|
+
"src/syntarus",
|
|
31
|
+
"tests",
|
|
32
|
+
"examples",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"pyproject.toml",
|
|
36
|
+
]
|
|
37
|
+
exclude = ["mcp/**", "memoryos_client.py", "**/__pycache__/**"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Official Syntarus project-scoped memory clients."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncMemoryClient, MemoryClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APITimeoutError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
EventFailedError,
|
|
9
|
+
PermissionDenied,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
SyntarusError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncMemoryClient", "MemoryClient", "SyntarusError", "AuthenticationError",
|
|
16
|
+
"PermissionDenied", "RateLimitError", "APIConnectionError", "APITimeoutError",
|
|
17
|
+
"EventFailedError",
|
|
18
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Synchronous and asynchronous clients for the Syntarus v1 memory API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
EventFailedError,
|
|
17
|
+
PermissionDenied,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
SyntarusError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://ai.syntarus.com/v1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _raise_for_response(response: httpx.Response) -> None:
|
|
26
|
+
if response.is_success:
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
detail = response.json().get("detail", "Request failed.")
|
|
30
|
+
except Exception:
|
|
31
|
+
detail = "Request failed."
|
|
32
|
+
if response.status_code == 401:
|
|
33
|
+
raise AuthenticationError("Project API key was rejected.")
|
|
34
|
+
if response.status_code == 403:
|
|
35
|
+
raise PermissionDenied(str(detail))
|
|
36
|
+
if response.status_code == 429:
|
|
37
|
+
raw_retry = response.headers.get("Retry-After")
|
|
38
|
+
try:
|
|
39
|
+
retry_after = float(raw_retry) if raw_retry is not None else None
|
|
40
|
+
except ValueError:
|
|
41
|
+
retry_after = None
|
|
42
|
+
raise RateLimitError("Project API rate limit exceeded.", retry_after=retry_after)
|
|
43
|
+
raise SyntarusError(f"Syntarus API error ({response.status_code}): {detail}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _BaseClient:
|
|
47
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0):
|
|
48
|
+
if not api_key.startswith("sk_mem_"):
|
|
49
|
+
raise ValueError("api_key must be a Syntarus project API key")
|
|
50
|
+
self._base_url = base_url.rstrip("/")
|
|
51
|
+
self._headers = {"Authorization": f"Bearer {api_key}", "User-Agent": "syntarus-python/0.2.0"}
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _payload(user_id: str, messages: list[dict[str, str]], agent_id: str | None, run_id: str | None, metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
56
|
+
return {"user_id": user_id, "messages": messages, "agent_id": agent_id, "run_id": run_id, "metadata": metadata or {}}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MemoryClient(_BaseClient):
|
|
60
|
+
"""Blocking client. Use ``AsyncMemoryClient`` inside async applications."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0):
|
|
63
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout)
|
|
64
|
+
self._client = httpx.Client(base_url=self._base_url, headers=self._headers, timeout=self._timeout)
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._client.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> "MemoryClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *_args: object) -> None:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
76
|
+
try:
|
|
77
|
+
response = self._client.request(method, path, **kwargs)
|
|
78
|
+
except httpx.TimeoutException as exc:
|
|
79
|
+
raise APITimeoutError("Syntarus API request timed out.") from exc
|
|
80
|
+
except httpx.RequestError as exc:
|
|
81
|
+
raise APIConnectionError("Could not connect to the Syntarus API.") from exc
|
|
82
|
+
_raise_for_response(response)
|
|
83
|
+
return response
|
|
84
|
+
|
|
85
|
+
def add(self, *, user_id: str, messages: list[dict[str, str]], agent_id: str | None = None, run_id: str | None = None, metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
86
|
+
response = self._request("POST", "/memories", json=self._payload(user_id, messages, agent_id, run_id, metadata), headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex})
|
|
87
|
+
return response.json()
|
|
88
|
+
|
|
89
|
+
def event(self, event_id: str) -> dict[str, Any]:
|
|
90
|
+
response = self._request("GET", f"/events/{event_id}")
|
|
91
|
+
return response.json()
|
|
92
|
+
|
|
93
|
+
def wait_for_event(self, event_id: str, *, timeout: float = 60.0, poll_interval: float = 0.5) -> dict[str, Any]:
|
|
94
|
+
if timeout <= 0 or poll_interval <= 0:
|
|
95
|
+
raise ValueError("timeout and poll_interval must be positive")
|
|
96
|
+
deadline = time.monotonic() + timeout
|
|
97
|
+
while True:
|
|
98
|
+
event = self.event(event_id)
|
|
99
|
+
if event.get("status") == "succeeded":
|
|
100
|
+
return event
|
|
101
|
+
if event.get("status") in {"dead_letter", "cancelled"}:
|
|
102
|
+
raise EventFailedError(event)
|
|
103
|
+
if time.monotonic() >= deadline:
|
|
104
|
+
raise APITimeoutError(f"Timed out waiting for event {event_id}.")
|
|
105
|
+
time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
|
|
106
|
+
|
|
107
|
+
def search(self, query: str, *, user_id: str, top_k: int = 10, agent_id: str | None = None) -> dict[str, Any]:
|
|
108
|
+
response = self._request("POST", "/memories/search", json={"user_id": user_id, "query": query, "top_k": top_k, "agent_id": agent_id})
|
|
109
|
+
return response.json()
|
|
110
|
+
|
|
111
|
+
def export(self, *, user_id: str, agent_id: str | None = None) -> dict[str, Any]:
|
|
112
|
+
params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
|
|
113
|
+
response = self._request("GET", "/memories/export", params=params)
|
|
114
|
+
return response.json()
|
|
115
|
+
|
|
116
|
+
def delete(self, memory_id: str) -> None:
|
|
117
|
+
self._request("DELETE", f"/memories/{memory_id}")
|
|
118
|
+
|
|
119
|
+
def delete_user(self, *, user_id: str, agent_id: str | None = None) -> None:
|
|
120
|
+
params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
|
|
121
|
+
self._request("DELETE", "/memories", params=params)
|
|
122
|
+
|
|
123
|
+
def create_webhook(self, url: str, *, event_types: list[str] | None = None) -> dict[str, Any]:
|
|
124
|
+
response = self._request("POST", "/webhooks", json={"url": url, "event_types": event_types or ["memory.ingest.succeeded"]})
|
|
125
|
+
return response.json()
|
|
126
|
+
|
|
127
|
+
def list_webhooks(self) -> dict[str, Any]:
|
|
128
|
+
return self._request("GET", "/webhooks").json()
|
|
129
|
+
|
|
130
|
+
def webhook_deliveries(self, *, webhook_id: str | None = None, limit: int = 100) -> dict[str, Any]:
|
|
131
|
+
params: dict[str, Any] = {"limit": limit}
|
|
132
|
+
if webhook_id:
|
|
133
|
+
params["webhook_id"] = webhook_id
|
|
134
|
+
return self._request("GET", "/webhooks/deliveries", params=params).json()
|
|
135
|
+
|
|
136
|
+
def delete_webhook(self, webhook_id: str) -> None:
|
|
137
|
+
self._request("DELETE", f"/webhooks/{webhook_id}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AsyncMemoryClient(_BaseClient):
|
|
141
|
+
"""Async client for FastAPI, LangGraph and other async agent runtimes."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 20.0):
|
|
144
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout)
|
|
145
|
+
self._client = httpx.AsyncClient(base_url=self._base_url, headers=self._headers, timeout=self._timeout)
|
|
146
|
+
|
|
147
|
+
async def aclose(self) -> None:
|
|
148
|
+
await self._client.aclose()
|
|
149
|
+
|
|
150
|
+
async def __aenter__(self) -> "AsyncMemoryClient":
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
154
|
+
await self.aclose()
|
|
155
|
+
|
|
156
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
157
|
+
try:
|
|
158
|
+
response = await self._client.request(method, path, **kwargs)
|
|
159
|
+
except httpx.TimeoutException as exc:
|
|
160
|
+
raise APITimeoutError("Syntarus API request timed out.") from exc
|
|
161
|
+
except httpx.RequestError as exc:
|
|
162
|
+
raise APIConnectionError("Could not connect to the Syntarus API.") from exc
|
|
163
|
+
_raise_for_response(response)
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
async def add(self, *, user_id: str, messages: list[dict[str, str]], agent_id: str | None = None, run_id: str | None = None, metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
167
|
+
response = await self._request("POST", "/memories", json=self._payload(user_id, messages, agent_id, run_id, metadata), headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex})
|
|
168
|
+
return response.json()
|
|
169
|
+
|
|
170
|
+
async def event(self, event_id: str) -> dict[str, Any]:
|
|
171
|
+
response = await self._request("GET", f"/events/{event_id}")
|
|
172
|
+
return response.json()
|
|
173
|
+
|
|
174
|
+
async def wait_for_event(self, event_id: str, *, timeout: float = 60.0, poll_interval: float = 0.5) -> dict[str, Any]:
|
|
175
|
+
if timeout <= 0 or poll_interval <= 0:
|
|
176
|
+
raise ValueError("timeout and poll_interval must be positive")
|
|
177
|
+
deadline = time.monotonic() + timeout
|
|
178
|
+
while True:
|
|
179
|
+
event = await self.event(event_id)
|
|
180
|
+
if event.get("status") == "succeeded":
|
|
181
|
+
return event
|
|
182
|
+
if event.get("status") in {"dead_letter", "cancelled"}:
|
|
183
|
+
raise EventFailedError(event)
|
|
184
|
+
if time.monotonic() >= deadline:
|
|
185
|
+
raise APITimeoutError(f"Timed out waiting for event {event_id}.")
|
|
186
|
+
await asyncio.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
|
|
187
|
+
|
|
188
|
+
async def search(self, query: str, *, user_id: str, top_k: int = 10, agent_id: str | None = None) -> dict[str, Any]:
|
|
189
|
+
response = await self._request("POST", "/memories/search", json={"user_id": user_id, "query": query, "top_k": top_k, "agent_id": agent_id})
|
|
190
|
+
return response.json()
|
|
191
|
+
|
|
192
|
+
async def export(self, *, user_id: str, agent_id: str | None = None) -> dict[str, Any]:
|
|
193
|
+
params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
|
|
194
|
+
response = await self._request("GET", "/memories/export", params=params)
|
|
195
|
+
return response.json()
|
|
196
|
+
|
|
197
|
+
async def delete(self, memory_id: str) -> None:
|
|
198
|
+
await self._request("DELETE", f"/memories/{memory_id}")
|
|
199
|
+
|
|
200
|
+
async def delete_user(self, *, user_id: str, agent_id: str | None = None) -> None:
|
|
201
|
+
params = {"user_id": user_id, **({"agent_id": agent_id} if agent_id else {})}
|
|
202
|
+
await self._request("DELETE", "/memories", params=params)
|
|
203
|
+
|
|
204
|
+
async def create_webhook(self, url: str, *, event_types: list[str] | None = None) -> dict[str, Any]:
|
|
205
|
+
response = await self._request("POST", "/webhooks", json={"url": url, "event_types": event_types or ["memory.ingest.succeeded"]})
|
|
206
|
+
return response.json()
|
|
207
|
+
|
|
208
|
+
async def list_webhooks(self) -> dict[str, Any]:
|
|
209
|
+
return (await self._request("GET", "/webhooks")).json()
|
|
210
|
+
|
|
211
|
+
async def webhook_deliveries(self, *, webhook_id: str | None = None, limit: int = 100) -> dict[str, Any]:
|
|
212
|
+
params: dict[str, Any] = {"limit": limit}
|
|
213
|
+
if webhook_id:
|
|
214
|
+
params["webhook_id"] = webhook_id
|
|
215
|
+
response = await self._request("GET", "/webhooks/deliveries", params=params)
|
|
216
|
+
return response.json()
|
|
217
|
+
|
|
218
|
+
async def delete_webhook(self, webhook_id: str) -> None:
|
|
219
|
+
await self._request("DELETE", f"/webhooks/{webhook_id}")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Typed API errors. Exception messages intentionally omit request secrets."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SyntarusError(Exception):
|
|
5
|
+
"""Base exception raised for an unsuccessful Syntarus API response."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(SyntarusError):
|
|
9
|
+
"""The project API key is invalid, expired, or revoked."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PermissionDenied(SyntarusError):
|
|
13
|
+
"""The project API key does not have the required scope."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitError(SyntarusError):
|
|
17
|
+
"""The project API key has exceeded its request budget."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, retry_after: float | None = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class APIConnectionError(SyntarusError):
|
|
25
|
+
"""The Syntarus API could not be reached."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class APITimeoutError(APIConnectionError):
|
|
29
|
+
"""The Syntarus API did not respond before the configured timeout."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EventFailedError(SyntarusError):
|
|
33
|
+
"""An accepted asynchronous memory event reached a terminal failure."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, event: dict):
|
|
36
|
+
self.event = event
|
|
37
|
+
detail = event.get("last_error") or f"event ended with status {event.get('status')}"
|
|
38
|
+
super().__init__(str(detail))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from syntarus import AsyncMemoryClient, EventFailedError, MemoryClient, RateLimitError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sync_client(handler) -> MemoryClient:
|
|
12
|
+
client = MemoryClient("sk_mem_test", base_url="https://api.test/v1")
|
|
13
|
+
client._client.close()
|
|
14
|
+
client._client = httpx.Client(
|
|
15
|
+
base_url="https://api.test/v1",
|
|
16
|
+
headers=client._headers,
|
|
17
|
+
transport=httpx.MockTransport(handler),
|
|
18
|
+
)
|
|
19
|
+
return client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _async_client(handler) -> AsyncMemoryClient:
|
|
23
|
+
client = AsyncMemoryClient("sk_mem_test", base_url="https://api.test/v1")
|
|
24
|
+
client._client = httpx.AsyncClient(
|
|
25
|
+
base_url="https://api.test/v1",
|
|
26
|
+
headers=client._headers,
|
|
27
|
+
transport=httpx.MockTransport(handler),
|
|
28
|
+
)
|
|
29
|
+
return client
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_add_sends_scope_metadata_and_idempotency_key():
|
|
33
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
34
|
+
assert request.url.path == "/v1/memories"
|
|
35
|
+
assert request.headers["authorization"] == "Bearer sk_mem_test"
|
|
36
|
+
assert request.headers["idempotency-key"] == "call-1"
|
|
37
|
+
body = json.loads(request.content)
|
|
38
|
+
assert body["agent_id"] == "support"
|
|
39
|
+
assert body["run_id"] == "ticket-1"
|
|
40
|
+
assert body["metadata"] == {"channel": "voice"}
|
|
41
|
+
return httpx.Response(202, json={"event_id": "evt_1", "status": "pending"})
|
|
42
|
+
|
|
43
|
+
with _sync_client(handler) as client:
|
|
44
|
+
response = client.add(
|
|
45
|
+
user_id="customer_1",
|
|
46
|
+
agent_id="support",
|
|
47
|
+
run_id="ticket-1",
|
|
48
|
+
metadata={"channel": "voice"},
|
|
49
|
+
messages=[{"role": "user", "content": "hello"}],
|
|
50
|
+
idempotency_key="call-1",
|
|
51
|
+
)
|
|
52
|
+
assert response["event_id"] == "evt_1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_wait_for_event_returns_success_without_extra_sleep():
|
|
56
|
+
statuses = iter(["processing", "succeeded"])
|
|
57
|
+
|
|
58
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
59
|
+
status = next(statuses)
|
|
60
|
+
return httpx.Response(200, json={"id": "evt_1", "status": status})
|
|
61
|
+
|
|
62
|
+
with _sync_client(handler) as client:
|
|
63
|
+
event = client.wait_for_event("evt_1", timeout=1, poll_interval=0.001)
|
|
64
|
+
assert event["status"] == "succeeded"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_wait_for_event_raises_terminal_error():
|
|
68
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
69
|
+
return httpx.Response(200, json={"id": "evt_1", "status": "dead_letter", "last_error": "extractor failed"})
|
|
70
|
+
|
|
71
|
+
with _sync_client(handler) as client, pytest.raises(EventFailedError, match="extractor failed") as exc:
|
|
72
|
+
client.wait_for_event("evt_1", timeout=1, poll_interval=0.001)
|
|
73
|
+
assert exc.value.event["id"] == "evt_1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_rate_limit_exposes_retry_after():
|
|
77
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
78
|
+
return httpx.Response(429, headers={"Retry-After": "7"}, json={"detail": "slow down"})
|
|
79
|
+
|
|
80
|
+
with _sync_client(handler) as client, pytest.raises(RateLimitError) as exc:
|
|
81
|
+
client.search("hello", user_id="customer_1")
|
|
82
|
+
assert exc.value.retry_after == 7
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_webhook_management_surface():
|
|
86
|
+
requests: list[httpx.Request] = []
|
|
87
|
+
|
|
88
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
89
|
+
requests.append(request)
|
|
90
|
+
if request.method == "POST":
|
|
91
|
+
return httpx.Response(201, json={"webhook": {"id": "wh_1"}, "signing_secret": "secret"})
|
|
92
|
+
if request.method == "GET" and request.url.path.endswith("/deliveries"):
|
|
93
|
+
return httpx.Response(200, json={"deliveries": []})
|
|
94
|
+
if request.method == "GET":
|
|
95
|
+
return httpx.Response(200, json={"webhooks": []})
|
|
96
|
+
return httpx.Response(204)
|
|
97
|
+
|
|
98
|
+
with _sync_client(handler) as client:
|
|
99
|
+
created = client.create_webhook("https://example.com/events")
|
|
100
|
+
client.list_webhooks()
|
|
101
|
+
client.webhook_deliveries(webhook_id="wh_1", limit=25)
|
|
102
|
+
client.delete_webhook("wh_1")
|
|
103
|
+
|
|
104
|
+
assert created["signing_secret"] == "secret"
|
|
105
|
+
assert requests[2].url.params["webhook_id"] == "wh_1"
|
|
106
|
+
assert requests[2].url.params["limit"] == "25"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_optional_query_parameters_are_omitted_not_sent_empty():
|
|
110
|
+
requests: list[httpx.Request] = []
|
|
111
|
+
|
|
112
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
113
|
+
requests.append(request)
|
|
114
|
+
if request.method == "GET" and request.url.path.endswith("/export"):
|
|
115
|
+
return httpx.Response(200, json={"memories": []})
|
|
116
|
+
if request.method == "GET":
|
|
117
|
+
return httpx.Response(200, json={"deliveries": []})
|
|
118
|
+
return httpx.Response(204)
|
|
119
|
+
|
|
120
|
+
with _sync_client(handler) as client:
|
|
121
|
+
client.export(user_id="customer_1")
|
|
122
|
+
client.delete_user(user_id="customer_1")
|
|
123
|
+
client.webhook_deliveries()
|
|
124
|
+
|
|
125
|
+
assert "agent_id" not in requests[0].url.params
|
|
126
|
+
assert "agent_id" not in requests[1].url.params
|
|
127
|
+
assert "webhook_id" not in requests[2].url.params
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_async_search_and_delete_are_agent_scoped():
|
|
132
|
+
requests: list[httpx.Request] = []
|
|
133
|
+
|
|
134
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
135
|
+
requests.append(request)
|
|
136
|
+
if request.method == "POST":
|
|
137
|
+
assert json.loads(request.content)["agent_id"] == "sales"
|
|
138
|
+
return httpx.Response(200, json={"results": [], "context": "", "latency_ms": 1})
|
|
139
|
+
return httpx.Response(204)
|
|
140
|
+
|
|
141
|
+
async with _async_client(handler) as client:
|
|
142
|
+
await client.search("preferences", user_id="customer_1", agent_id="sales")
|
|
143
|
+
await client.delete_user(user_id="customer_1", agent_id="sales")
|
|
144
|
+
|
|
145
|
+
assert requests[1].url.params["agent_id"] == "sales"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
|
|
6
|
+
from examples.webhook_verify import verify_signature
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_webhook_verifier_uses_raw_body_and_timestamp():
|
|
10
|
+
secret = "test-secret"
|
|
11
|
+
timestamp = "1700000000"
|
|
12
|
+
body = b'{"z":1,"a":2}'
|
|
13
|
+
signature = "sha256=" + hmac.new(
|
|
14
|
+
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
|
|
15
|
+
).hexdigest()
|
|
16
|
+
|
|
17
|
+
assert verify_signature(secret=secret, timestamp=timestamp, raw_body=body, signature=signature)
|
|
18
|
+
assert not verify_signature(secret=secret, timestamp=timestamp, raw_body=b'{"a":2,"z":1}', signature=signature)
|
|
19
|
+
assert not verify_signature(secret=secret, timestamp="1700000001", raw_body=body, signature=signature)
|