atomicmemory 1.0.0__py3-none-any.whl
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.
- atomicmemory/__init__.py +166 -0
- atomicmemory/_version.py +3 -0
- atomicmemory/client/__init__.py +22 -0
- atomicmemory/client/async_memory_client.py +202 -0
- atomicmemory/client/atomic_memory_client.py +181 -0
- atomicmemory/client/memory_client.py +292 -0
- atomicmemory/core/__init__.py +34 -0
- atomicmemory/core/errors.py +122 -0
- atomicmemory/core/events.py +65 -0
- atomicmemory/core/logging.py +37 -0
- atomicmemory/core/retry.py +124 -0
- atomicmemory/core/validation.py +22 -0
- atomicmemory/embeddings/__init__.py +16 -0
- atomicmemory/embeddings/base.py +39 -0
- atomicmemory/embeddings/sentence_transformers.py +104 -0
- atomicmemory/kv_cache/__init__.py +17 -0
- atomicmemory/kv_cache/adapter.py +50 -0
- atomicmemory/kv_cache/memory_storage.py +98 -0
- atomicmemory/kv_cache/sqlite_storage.py +122 -0
- atomicmemory/memory/__init__.py +82 -0
- atomicmemory/memory/filters.py +68 -0
- atomicmemory/memory/pipeline.py +42 -0
- atomicmemory/memory/provider.py +397 -0
- atomicmemory/memory/registry.py +95 -0
- atomicmemory/memory/service.py +199 -0
- atomicmemory/memory/types.py +398 -0
- atomicmemory/providers/__init__.py +5 -0
- atomicmemory/providers/atomicmemory/__init__.py +43 -0
- atomicmemory/providers/atomicmemory/agents.py +156 -0
- atomicmemory/providers/atomicmemory/async_handle_impl.py +198 -0
- atomicmemory/providers/atomicmemory/async_provider.py +245 -0
- atomicmemory/providers/atomicmemory/audit.py +74 -0
- atomicmemory/providers/atomicmemory/config.py +38 -0
- atomicmemory/providers/atomicmemory/config_handle.py +123 -0
- atomicmemory/providers/atomicmemory/handle.py +513 -0
- atomicmemory/providers/atomicmemory/handle_impl.py +325 -0
- atomicmemory/providers/atomicmemory/http.py +255 -0
- atomicmemory/providers/atomicmemory/lessons.py +133 -0
- atomicmemory/providers/atomicmemory/lifecycle.py +202 -0
- atomicmemory/providers/atomicmemory/mappers.py +125 -0
- atomicmemory/providers/atomicmemory/path.py +20 -0
- atomicmemory/providers/atomicmemory/provider.py +300 -0
- atomicmemory/providers/atomicmemory/scope_mapper.py +98 -0
- atomicmemory/providers/mem0/__init__.py +41 -0
- atomicmemory/providers/mem0/async_provider.py +191 -0
- atomicmemory/providers/mem0/config.py +51 -0
- atomicmemory/providers/mem0/http.py +195 -0
- atomicmemory/providers/mem0/mappers.py +145 -0
- atomicmemory/providers/mem0/provider.py +202 -0
- atomicmemory/py.typed +0 -0
- atomicmemory/search/__init__.py +47 -0
- atomicmemory/search/chunking.py +161 -0
- atomicmemory/search/ranking.py +94 -0
- atomicmemory/search/semantic_search.py +130 -0
- atomicmemory/search/similarity.py +110 -0
- atomicmemory/storage/__init__.py +63 -0
- atomicmemory/storage/_mapping.py +305 -0
- atomicmemory/storage/async_client.py +208 -0
- atomicmemory/storage/client.py +339 -0
- atomicmemory/storage/errors.py +115 -0
- atomicmemory/storage/types.py +305 -0
- atomicmemory/utils/__init__.py +5 -0
- atomicmemory/utils/environment.py +23 -0
- atomicmemory-1.0.0.dist-info/METADATA +146 -0
- atomicmemory-1.0.0.dist-info/RECORD +67 -0
- atomicmemory-1.0.0.dist-info/WHEEL +4 -0
- atomicmemory-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""AtomicMemoryHandle root implementation — base routes + categories.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/handle-impl.ts:78-191`
|
|
4
|
+
plus the namespace-specific memory/search mappers (lines 321-463).
|
|
5
|
+
|
|
6
|
+
This module owns the base 8 routes (ingestFull, ingestQuick, search,
|
|
7
|
+
searchFast, expand, list, get, delete) and exposes the five category
|
|
8
|
+
sub-handles. Per-category modules live alongside.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import quote, urlencode
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from atomicmemory.core.errors import ProviderError, ValidationError
|
|
21
|
+
from atomicmemory.providers.atomicmemory.agents import AtomicMemoryAgents
|
|
22
|
+
from atomicmemory.providers.atomicmemory.audit import AtomicMemoryAudit
|
|
23
|
+
from atomicmemory.providers.atomicmemory.config_handle import AtomicMemoryConfig
|
|
24
|
+
from atomicmemory.providers.atomicmemory.handle import (
|
|
25
|
+
AtomicMemoryIngestInput,
|
|
26
|
+
AtomicMemoryIngestResult,
|
|
27
|
+
AtomicMemoryListOptions,
|
|
28
|
+
AtomicMemoryListResultPage,
|
|
29
|
+
AtomicMemoryMemory,
|
|
30
|
+
AtomicMemorySearchRequest,
|
|
31
|
+
AtomicMemorySearchResult,
|
|
32
|
+
AtomicMemorySearchResultPage,
|
|
33
|
+
MemoryScope,
|
|
34
|
+
WorkspaceScope,
|
|
35
|
+
)
|
|
36
|
+
from atomicmemory.providers.atomicmemory.http import HttpOptions, fetch_json, fetch_json_or_none, fetch_void
|
|
37
|
+
from atomicmemory.providers.atomicmemory.lessons import AtomicMemoryLessons
|
|
38
|
+
from atomicmemory.providers.atomicmemory.lifecycle import AtomicMemoryLifecycle
|
|
39
|
+
from atomicmemory.providers.atomicmemory.scope_mapper import (
|
|
40
|
+
assert_scope_allows_visibility,
|
|
41
|
+
scope_to_fields,
|
|
42
|
+
scope_to_query_pairs,
|
|
43
|
+
strip_agent_scope,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Route = Callable[[str], str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AtomicMemoryHandle:
|
|
50
|
+
"""Typed access to AtomicMemory-specific routes.
|
|
51
|
+
|
|
52
|
+
Constructed by :class:`AtomicMemoryProvider` and exposed via
|
|
53
|
+
``MemoryClient.atomicmemory``. Base routes hang off this object;
|
|
54
|
+
category handles hang off ``.lifecycle``, ``.audit``, ``.lessons``,
|
|
55
|
+
``.config``, ``.agents``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, client: httpx.Client, http: HttpOptions, route: Route) -> None:
|
|
59
|
+
self._client = client
|
|
60
|
+
self._http = http
|
|
61
|
+
self._route = route
|
|
62
|
+
self.lifecycle = AtomicMemoryLifecycle(client, http, route)
|
|
63
|
+
self.audit = AtomicMemoryAudit(client, http, route)
|
|
64
|
+
self.lessons = AtomicMemoryLessons(client, http, route)
|
|
65
|
+
self.config = AtomicMemoryConfig(client, http, route)
|
|
66
|
+
self.agents = AtomicMemoryAgents(client, http, route)
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Base routes
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def ingest_full(self, input: AtomicMemoryIngestInput, scope: MemoryScope) -> AtomicMemoryIngestResult:
|
|
73
|
+
return self._post_ingest(self._route("/memories/ingest"), input, scope)
|
|
74
|
+
|
|
75
|
+
def ingest_quick(
|
|
76
|
+
self,
|
|
77
|
+
input: AtomicMemoryIngestInput,
|
|
78
|
+
scope: MemoryScope,
|
|
79
|
+
skip_extraction: bool = False,
|
|
80
|
+
) -> AtomicMemoryIngestResult:
|
|
81
|
+
return self._post_ingest(self._route("/memories/ingest/quick"), input, scope, skip_extraction=skip_extraction)
|
|
82
|
+
|
|
83
|
+
def search(self, request: AtomicMemorySearchRequest, scope: MemoryScope) -> AtomicMemorySearchResultPage:
|
|
84
|
+
return self._post_search(self._route("/memories/search"), request, scope)
|
|
85
|
+
|
|
86
|
+
def search_fast(self, request: AtomicMemorySearchRequest, scope: MemoryScope) -> AtomicMemorySearchResultPage:
|
|
87
|
+
# Core's fast-search handler parses `as_of` but drops it; we still
|
|
88
|
+
# send it for forward-compat per TS handle-impl.
|
|
89
|
+
return self._post_search(self._route("/memories/search/fast"), request, scope)
|
|
90
|
+
|
|
91
|
+
def expand(self, refs: list[str], scope: MemoryScope) -> list[AtomicMemoryMemory]:
|
|
92
|
+
body: dict[str, Any] = {**scope_to_fields(scope), "memory_ids": refs}
|
|
93
|
+
raw = fetch_json(
|
|
94
|
+
self._client,
|
|
95
|
+
self._http,
|
|
96
|
+
self._route("/memories/expand"),
|
|
97
|
+
method="POST",
|
|
98
|
+
json=body,
|
|
99
|
+
)
|
|
100
|
+
echoed = strip_agent_scope(scope)
|
|
101
|
+
return [_to_atomic_memory(m, echoed) for m in raw.get("memories", [])]
|
|
102
|
+
|
|
103
|
+
def list(
|
|
104
|
+
self,
|
|
105
|
+
scope: MemoryScope,
|
|
106
|
+
options: AtomicMemoryListOptions | dict[str, Any] | None = None,
|
|
107
|
+
) -> AtomicMemoryListResultPage:
|
|
108
|
+
opts = _coerce_list_options(options)
|
|
109
|
+
_assert_list_options_scope_compat(scope, opts)
|
|
110
|
+
pairs: list[tuple[str, str]] = scope_to_query_pairs(scope)
|
|
111
|
+
if opts.limit is not None:
|
|
112
|
+
pairs.append(("limit", str(opts.limit)))
|
|
113
|
+
if opts.offset is not None:
|
|
114
|
+
pairs.append(("offset", str(opts.offset)))
|
|
115
|
+
if opts.source_site:
|
|
116
|
+
pairs.append(("source_site", opts.source_site))
|
|
117
|
+
if opts.episode_id:
|
|
118
|
+
pairs.append(("episode_id", opts.episode_id))
|
|
119
|
+
path = self._route(f"/memories/list?{urlencode(pairs)}")
|
|
120
|
+
raw = fetch_json(self._client, self._http, path)
|
|
121
|
+
limit = opts.limit if opts.limit is not None else 20
|
|
122
|
+
offset = opts.offset if opts.offset is not None else 0
|
|
123
|
+
memories_raw = raw.get("memories", [])
|
|
124
|
+
next_offset = offset + len(memories_raw)
|
|
125
|
+
has_more = len(memories_raw) == limit
|
|
126
|
+
echoed = strip_agent_scope(scope)
|
|
127
|
+
return AtomicMemoryListResultPage(
|
|
128
|
+
memories=[_to_atomic_memory(m, echoed) for m in memories_raw],
|
|
129
|
+
count=raw.get("count", len(memories_raw)),
|
|
130
|
+
cursor=str(next_offset) if has_more else None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def get(self, id: str, scope: MemoryScope) -> AtomicMemoryMemory | None:
|
|
134
|
+
path = self._route(f"/memories/{quote(id, safe='')}?{urlencode(scope_to_query_pairs(scope))}")
|
|
135
|
+
raw = fetch_json_or_none(self._client, self._http, path)
|
|
136
|
+
if raw is None:
|
|
137
|
+
return None
|
|
138
|
+
return _to_atomic_memory(raw, strip_agent_scope(scope))
|
|
139
|
+
|
|
140
|
+
def delete(self, id: str, scope: MemoryScope) -> None:
|
|
141
|
+
path = self._route(f"/memories/{quote(id, safe='')}?{urlencode(scope_to_query_pairs(scope))}")
|
|
142
|
+
try:
|
|
143
|
+
fetch_void(self._client, self._http, path, method="DELETE")
|
|
144
|
+
except ProviderError as exc:
|
|
145
|
+
if exc.status_code == 404:
|
|
146
|
+
return
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Internals
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _post_ingest(
|
|
154
|
+
self,
|
|
155
|
+
path: str,
|
|
156
|
+
input: AtomicMemoryIngestInput,
|
|
157
|
+
scope: MemoryScope,
|
|
158
|
+
*,
|
|
159
|
+
skip_extraction: bool = False,
|
|
160
|
+
) -> AtomicMemoryIngestResult:
|
|
161
|
+
assert_scope_allows_visibility(scope, input.visibility)
|
|
162
|
+
body: dict[str, Any] = {
|
|
163
|
+
**scope_to_fields(scope),
|
|
164
|
+
"conversation": input.conversation,
|
|
165
|
+
"source_site": input.source_site,
|
|
166
|
+
"source_url": input.source_url or "",
|
|
167
|
+
}
|
|
168
|
+
if isinstance(scope, WorkspaceScope) and input.visibility:
|
|
169
|
+
body["visibility"] = input.visibility
|
|
170
|
+
if input.config_override is not None:
|
|
171
|
+
body["config_override"] = input.config_override
|
|
172
|
+
if skip_extraction:
|
|
173
|
+
body["skip_extraction"] = True
|
|
174
|
+
raw = fetch_json(self._client, self._http, path, method="POST", json=body)
|
|
175
|
+
return AtomicMemoryIngestResult.model_validate(raw)
|
|
176
|
+
|
|
177
|
+
def _post_search(
|
|
178
|
+
self,
|
|
179
|
+
path: str,
|
|
180
|
+
request: AtomicMemorySearchRequest,
|
|
181
|
+
scope: MemoryScope,
|
|
182
|
+
) -> AtomicMemorySearchResultPage:
|
|
183
|
+
body: dict[str, Any] = {
|
|
184
|
+
**scope_to_fields(scope, include_agent_scope=True),
|
|
185
|
+
"query": request.query,
|
|
186
|
+
}
|
|
187
|
+
if request.limit is not None:
|
|
188
|
+
body["limit"] = request.limit
|
|
189
|
+
if request.threshold is not None:
|
|
190
|
+
body["threshold"] = request.threshold
|
|
191
|
+
if request.as_of is not None:
|
|
192
|
+
body["as_of"] = request.as_of.isoformat()
|
|
193
|
+
if request.retrieval_mode is not None:
|
|
194
|
+
body["retrieval_mode"] = request.retrieval_mode
|
|
195
|
+
if request.token_budget is not None:
|
|
196
|
+
body["token_budget"] = request.token_budget
|
|
197
|
+
if request.namespace_scope is not None:
|
|
198
|
+
body["namespace_scope"] = request.namespace_scope
|
|
199
|
+
if request.source_site is not None:
|
|
200
|
+
body["source_site"] = request.source_site
|
|
201
|
+
if request.skip_repair:
|
|
202
|
+
body["skip_repair"] = True
|
|
203
|
+
if request.config_override is not None:
|
|
204
|
+
body["config_override"] = request.config_override
|
|
205
|
+
raw = fetch_json(self._client, self._http, path, method="POST", json=body)
|
|
206
|
+
return _map_search_response(raw, scope)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Mapping helpers (namespace-specific; do NOT reuse V3's mappers)
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _coerce_list_options(
|
|
215
|
+
options: AtomicMemoryListOptions | dict[str, Any] | None,
|
|
216
|
+
) -> AtomicMemoryListOptions:
|
|
217
|
+
if options is None:
|
|
218
|
+
return AtomicMemoryListOptions()
|
|
219
|
+
if isinstance(options, dict):
|
|
220
|
+
return AtomicMemoryListOptions.model_validate(options)
|
|
221
|
+
return options
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _assert_list_options_scope_compat(scope: MemoryScope, options: AtomicMemoryListOptions) -> None:
|
|
225
|
+
"""Reject options core silently drops on workspace lists.
|
|
226
|
+
|
|
227
|
+
``source_site`` and ``episode_id`` are user-scope only. Core
|
|
228
|
+
ignores them on workspace lists *but still validates* episode_id as
|
|
229
|
+
a UUID before branching, which can surface as a misleading 400.
|
|
230
|
+
Fail closed in the SDK so the mismatch surfaces at the call site.
|
|
231
|
+
"""
|
|
232
|
+
if not isinstance(scope, WorkspaceScope):
|
|
233
|
+
return
|
|
234
|
+
if options.source_site is not None:
|
|
235
|
+
raise ValidationError(
|
|
236
|
+
"`source_site` is only valid on user scope; core ignores it on "
|
|
237
|
+
"workspace list queries. Omit the option or use a user-scope list.",
|
|
238
|
+
context={"scope_kind": "workspace"},
|
|
239
|
+
)
|
|
240
|
+
if options.episode_id is not None:
|
|
241
|
+
raise ValidationError(
|
|
242
|
+
"`episode_id` is only valid on user scope; core ignores it on "
|
|
243
|
+
"workspace list queries but still validates it as a UUID first. "
|
|
244
|
+
"Omit the option or use a user-scope list.",
|
|
245
|
+
context={"scope_kind": "workspace"},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _coalesce(*values: Any) -> Any:
|
|
250
|
+
for value in values:
|
|
251
|
+
if value is not None:
|
|
252
|
+
return value
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _to_atomic_memory(raw: dict[str, Any], scope: MemoryScope) -> AtomicMemoryMemory:
|
|
257
|
+
"""Map raw core memory to AtomicMemoryMemory, preserving full scope.
|
|
258
|
+
|
|
259
|
+
Distinct from V3's ``to_memory`` which flattens scope to a `Scope`
|
|
260
|
+
and drops ``importance`` into metadata. The namespace surface keeps
|
|
261
|
+
those as first-class fields.
|
|
262
|
+
"""
|
|
263
|
+
payload: dict[str, Any] = {
|
|
264
|
+
"id": raw["id"],
|
|
265
|
+
"content": raw.get("content") or "",
|
|
266
|
+
"scope": scope,
|
|
267
|
+
"created_at": _parse_iso(raw.get("created_at")) or _now_utc(),
|
|
268
|
+
}
|
|
269
|
+
if raw.get("updated_at"):
|
|
270
|
+
payload["updated_at"] = _parse_iso(raw["updated_at"])
|
|
271
|
+
for field in ("importance", "source_site", "source_url", "episode_id", "visibility", "metadata"):
|
|
272
|
+
if raw.get(field) is not None:
|
|
273
|
+
payload[field] = raw[field]
|
|
274
|
+
return AtomicMemoryMemory.model_validate(payload)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _to_atomic_search_result(raw: dict[str, Any], scope: MemoryScope) -> AtomicMemorySearchResult:
|
|
278
|
+
similarity = _coalesce(raw.get("semantic_similarity"), raw.get("similarity"))
|
|
279
|
+
ranking_score = _coalesce(raw.get("ranking_score"), raw.get("score"))
|
|
280
|
+
relevance = raw.get("relevance")
|
|
281
|
+
score = _coalesce(ranking_score, similarity, 0.0)
|
|
282
|
+
payload: dict[str, Any] = {
|
|
283
|
+
"memory": _to_atomic_memory(raw, scope),
|
|
284
|
+
"score": score,
|
|
285
|
+
}
|
|
286
|
+
if similarity is not None:
|
|
287
|
+
payload["similarity"] = similarity
|
|
288
|
+
if ranking_score is not None:
|
|
289
|
+
payload["ranking_score"] = ranking_score
|
|
290
|
+
if relevance is not None:
|
|
291
|
+
payload["relevance"] = relevance
|
|
292
|
+
if raw.get("importance") is not None:
|
|
293
|
+
payload["importance"] = raw["importance"]
|
|
294
|
+
return AtomicMemorySearchResult.model_validate(payload)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _map_search_response(raw: dict[str, Any], scope: MemoryScope) -> AtomicMemorySearchResultPage:
|
|
298
|
+
memories_raw = raw.get("memories") or []
|
|
299
|
+
payload: dict[str, Any] = {
|
|
300
|
+
"count": raw.get("count", len(memories_raw)),
|
|
301
|
+
"retrieval_mode": raw.get("retrieval_mode") or "flat",
|
|
302
|
+
"scope": scope,
|
|
303
|
+
"results": [_to_atomic_search_result(m, scope) for m in memories_raw],
|
|
304
|
+
}
|
|
305
|
+
if raw.get("injection_text") is not None:
|
|
306
|
+
payload["injection_text"] = raw["injection_text"]
|
|
307
|
+
for field in ("citations", "tier_assignments", "expand_ids", "lesson_check", "consensus", "observability"):
|
|
308
|
+
if raw.get(field) is not None:
|
|
309
|
+
payload[field] = raw[field]
|
|
310
|
+
if raw.get("estimated_context_tokens") is not None:
|
|
311
|
+
payload["estimated_context_tokens"] = raw["estimated_context_tokens"]
|
|
312
|
+
return AtomicMemorySearchResultPage.model_validate(payload)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _parse_iso(value: str | None) -> datetime | None:
|
|
316
|
+
if value is None:
|
|
317
|
+
return None
|
|
318
|
+
text = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
|
319
|
+
return datetime.fromisoformat(text)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _now_utc() -> datetime:
|
|
323
|
+
from datetime import timezone
|
|
324
|
+
|
|
325
|
+
return datetime.now(tz=timezone.utc)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Sync HTTP transport for the AtomicMemory provider.
|
|
2
|
+
|
|
3
|
+
Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/http.ts` and
|
|
4
|
+
the shared client at `atomicmemory-sdk/src/memory/shared/http-client.ts`.
|
|
5
|
+
|
|
6
|
+
This module owns the wire-error mapping policy:
|
|
7
|
+
|
|
8
|
+
- 429 → ``RateLimitError`` (with optional ``Retry-After`` seconds).
|
|
9
|
+
- non-2xx → ``ProviderError`` with ``status_code`` and decoded body.
|
|
10
|
+
- transport failure (timeout, DNS, connection) → ``NetworkError``.
|
|
11
|
+
- 404 is special-cased on ``fetch_json_or_none`` and ``delete_ignore_404``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from atomicmemory.core.errors import NetworkError, ProviderError, RateLimitError
|
|
22
|
+
|
|
23
|
+
_PROVIDER_NAME = "atomicmemory"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HttpOptions:
|
|
28
|
+
"""Per-request transport options, derived from provider config."""
|
|
29
|
+
|
|
30
|
+
api_url: str
|
|
31
|
+
api_key: str | None
|
|
32
|
+
timeout_seconds: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _headers(options: HttpOptions, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
36
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
37
|
+
if options.api_key:
|
|
38
|
+
headers["Authorization"] = f"Bearer {options.api_key}"
|
|
39
|
+
if extra:
|
|
40
|
+
headers.update(extra)
|
|
41
|
+
return headers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
45
|
+
if not value:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
return float(value)
|
|
49
|
+
except ValueError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _raise_for_status(response: httpx.Response, path: str) -> None:
|
|
54
|
+
if response.status_code == 429:
|
|
55
|
+
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
56
|
+
raise RateLimitError(
|
|
57
|
+
"Rate limited",
|
|
58
|
+
provider=_PROVIDER_NAME,
|
|
59
|
+
retry_after_seconds=retry_after,
|
|
60
|
+
context={"path": path},
|
|
61
|
+
)
|
|
62
|
+
if response.is_success:
|
|
63
|
+
return
|
|
64
|
+
body_text = response.text
|
|
65
|
+
body_decoded: Any = body_text
|
|
66
|
+
try:
|
|
67
|
+
body_decoded = response.json()
|
|
68
|
+
except (ValueError, httpx.DecodingError):
|
|
69
|
+
body_decoded = body_text
|
|
70
|
+
raise ProviderError(
|
|
71
|
+
f"HTTP {response.status_code}: {body_text or response.reason_phrase}",
|
|
72
|
+
provider=_PROVIDER_NAME,
|
|
73
|
+
status_code=response.status_code,
|
|
74
|
+
response_body=body_decoded,
|
|
75
|
+
context={"path": path},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _request(
|
|
80
|
+
client: httpx.Client,
|
|
81
|
+
options: HttpOptions,
|
|
82
|
+
method: str,
|
|
83
|
+
path: str,
|
|
84
|
+
*,
|
|
85
|
+
json: Any | None = None,
|
|
86
|
+
headers: dict[str, str] | None = None,
|
|
87
|
+
) -> httpx.Response:
|
|
88
|
+
url = f"{options.api_url}{path}"
|
|
89
|
+
try:
|
|
90
|
+
return client.request(
|
|
91
|
+
method,
|
|
92
|
+
url,
|
|
93
|
+
headers=_headers(options, headers),
|
|
94
|
+
json=json,
|
|
95
|
+
timeout=options.timeout_seconds,
|
|
96
|
+
)
|
|
97
|
+
except httpx.TimeoutException as exc:
|
|
98
|
+
raise NetworkError(
|
|
99
|
+
f"Timeout after {options.timeout_seconds}s",
|
|
100
|
+
provider=_PROVIDER_NAME,
|
|
101
|
+
cause=exc,
|
|
102
|
+
context={"path": path, "method": method},
|
|
103
|
+
) from exc
|
|
104
|
+
except httpx.RequestError as exc:
|
|
105
|
+
raise NetworkError(
|
|
106
|
+
f"Transport error: {exc}",
|
|
107
|
+
provider=_PROVIDER_NAME,
|
|
108
|
+
cause=exc,
|
|
109
|
+
context={"path": path, "method": method},
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fetch_json(
|
|
114
|
+
client: httpx.Client,
|
|
115
|
+
options: HttpOptions,
|
|
116
|
+
path: str,
|
|
117
|
+
*,
|
|
118
|
+
method: str = "GET",
|
|
119
|
+
json: Any | None = None,
|
|
120
|
+
) -> Any:
|
|
121
|
+
"""Send a request and return the decoded JSON response body."""
|
|
122
|
+
response = _request(client, options, method, path, json=json)
|
|
123
|
+
_raise_for_status(response, path)
|
|
124
|
+
return response.json()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fetch_void(
|
|
128
|
+
client: httpx.Client,
|
|
129
|
+
options: HttpOptions,
|
|
130
|
+
path: str,
|
|
131
|
+
*,
|
|
132
|
+
method: str = "GET",
|
|
133
|
+
json: Any | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Send a request and discard the body."""
|
|
136
|
+
response = _request(client, options, method, path, json=json)
|
|
137
|
+
_raise_for_status(response, path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def fetch_json_or_none(
|
|
141
|
+
client: httpx.Client,
|
|
142
|
+
options: HttpOptions,
|
|
143
|
+
path: str,
|
|
144
|
+
*,
|
|
145
|
+
method: str = "GET",
|
|
146
|
+
json: Any | None = None,
|
|
147
|
+
) -> Any | None:
|
|
148
|
+
"""Send a request; return None on 404, decoded JSON otherwise."""
|
|
149
|
+
response = _request(client, options, method, path, json=json)
|
|
150
|
+
if response.status_code == 404:
|
|
151
|
+
return None
|
|
152
|
+
_raise_for_status(response, path)
|
|
153
|
+
return response.json()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def delete_ignore_404(
|
|
157
|
+
client: httpx.Client,
|
|
158
|
+
options: HttpOptions,
|
|
159
|
+
path: str,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""DELETE that swallows 404 (V3 contract: missing target = success)."""
|
|
162
|
+
response = _request(client, options, "DELETE", path)
|
|
163
|
+
if response.status_code == 404:
|
|
164
|
+
return
|
|
165
|
+
_raise_for_status(response, path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Async transport — paired with the sync helpers above.
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _arequest(
|
|
174
|
+
client: httpx.AsyncClient,
|
|
175
|
+
options: HttpOptions,
|
|
176
|
+
method: str,
|
|
177
|
+
path: str,
|
|
178
|
+
*,
|
|
179
|
+
json: Any | None = None,
|
|
180
|
+
headers: dict[str, str] | None = None,
|
|
181
|
+
) -> httpx.Response:
|
|
182
|
+
url = f"{options.api_url}{path}"
|
|
183
|
+
try:
|
|
184
|
+
return await client.request(
|
|
185
|
+
method,
|
|
186
|
+
url,
|
|
187
|
+
headers=_headers(options, headers),
|
|
188
|
+
json=json,
|
|
189
|
+
timeout=options.timeout_seconds,
|
|
190
|
+
)
|
|
191
|
+
except httpx.TimeoutException as exc:
|
|
192
|
+
raise NetworkError(
|
|
193
|
+
f"Timeout after {options.timeout_seconds}s",
|
|
194
|
+
provider=_PROVIDER_NAME,
|
|
195
|
+
cause=exc,
|
|
196
|
+
context={"path": path, "method": method},
|
|
197
|
+
) from exc
|
|
198
|
+
except httpx.RequestError as exc:
|
|
199
|
+
raise NetworkError(
|
|
200
|
+
f"Transport error: {exc}",
|
|
201
|
+
provider=_PROVIDER_NAME,
|
|
202
|
+
cause=exc,
|
|
203
|
+
context={"path": path, "method": method},
|
|
204
|
+
) from exc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def afetch_json(
|
|
208
|
+
client: httpx.AsyncClient,
|
|
209
|
+
options: HttpOptions,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
method: str = "GET",
|
|
213
|
+
json: Any | None = None,
|
|
214
|
+
) -> Any:
|
|
215
|
+
response = await _arequest(client, options, method, path, json=json)
|
|
216
|
+
_raise_for_status(response, path)
|
|
217
|
+
return response.json()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def afetch_void(
|
|
221
|
+
client: httpx.AsyncClient,
|
|
222
|
+
options: HttpOptions,
|
|
223
|
+
path: str,
|
|
224
|
+
*,
|
|
225
|
+
method: str = "GET",
|
|
226
|
+
json: Any | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
response = await _arequest(client, options, method, path, json=json)
|
|
229
|
+
_raise_for_status(response, path)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def afetch_json_or_none(
|
|
233
|
+
client: httpx.AsyncClient,
|
|
234
|
+
options: HttpOptions,
|
|
235
|
+
path: str,
|
|
236
|
+
*,
|
|
237
|
+
method: str = "GET",
|
|
238
|
+
json: Any | None = None,
|
|
239
|
+
) -> Any | None:
|
|
240
|
+
response = await _arequest(client, options, method, path, json=json)
|
|
241
|
+
if response.status_code == 404:
|
|
242
|
+
return None
|
|
243
|
+
_raise_for_status(response, path)
|
|
244
|
+
return response.json()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def adelete_ignore_404(
|
|
248
|
+
client: httpx.AsyncClient,
|
|
249
|
+
options: HttpOptions,
|
|
250
|
+
path: str,
|
|
251
|
+
) -> None:
|
|
252
|
+
response = await _arequest(client, options, "DELETE", path)
|
|
253
|
+
if response.status_code == 404:
|
|
254
|
+
return
|
|
255
|
+
_raise_for_status(response, path)
|