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.
Files changed (67) hide show
  1. atomicmemory/__init__.py +166 -0
  2. atomicmemory/_version.py +3 -0
  3. atomicmemory/client/__init__.py +22 -0
  4. atomicmemory/client/async_memory_client.py +202 -0
  5. atomicmemory/client/atomic_memory_client.py +181 -0
  6. atomicmemory/client/memory_client.py +292 -0
  7. atomicmemory/core/__init__.py +34 -0
  8. atomicmemory/core/errors.py +122 -0
  9. atomicmemory/core/events.py +65 -0
  10. atomicmemory/core/logging.py +37 -0
  11. atomicmemory/core/retry.py +124 -0
  12. atomicmemory/core/validation.py +22 -0
  13. atomicmemory/embeddings/__init__.py +16 -0
  14. atomicmemory/embeddings/base.py +39 -0
  15. atomicmemory/embeddings/sentence_transformers.py +104 -0
  16. atomicmemory/kv_cache/__init__.py +17 -0
  17. atomicmemory/kv_cache/adapter.py +50 -0
  18. atomicmemory/kv_cache/memory_storage.py +98 -0
  19. atomicmemory/kv_cache/sqlite_storage.py +122 -0
  20. atomicmemory/memory/__init__.py +82 -0
  21. atomicmemory/memory/filters.py +68 -0
  22. atomicmemory/memory/pipeline.py +42 -0
  23. atomicmemory/memory/provider.py +397 -0
  24. atomicmemory/memory/registry.py +95 -0
  25. atomicmemory/memory/service.py +199 -0
  26. atomicmemory/memory/types.py +398 -0
  27. atomicmemory/providers/__init__.py +5 -0
  28. atomicmemory/providers/atomicmemory/__init__.py +43 -0
  29. atomicmemory/providers/atomicmemory/agents.py +156 -0
  30. atomicmemory/providers/atomicmemory/async_handle_impl.py +198 -0
  31. atomicmemory/providers/atomicmemory/async_provider.py +245 -0
  32. atomicmemory/providers/atomicmemory/audit.py +74 -0
  33. atomicmemory/providers/atomicmemory/config.py +38 -0
  34. atomicmemory/providers/atomicmemory/config_handle.py +123 -0
  35. atomicmemory/providers/atomicmemory/handle.py +513 -0
  36. atomicmemory/providers/atomicmemory/handle_impl.py +325 -0
  37. atomicmemory/providers/atomicmemory/http.py +255 -0
  38. atomicmemory/providers/atomicmemory/lessons.py +133 -0
  39. atomicmemory/providers/atomicmemory/lifecycle.py +202 -0
  40. atomicmemory/providers/atomicmemory/mappers.py +125 -0
  41. atomicmemory/providers/atomicmemory/path.py +20 -0
  42. atomicmemory/providers/atomicmemory/provider.py +300 -0
  43. atomicmemory/providers/atomicmemory/scope_mapper.py +98 -0
  44. atomicmemory/providers/mem0/__init__.py +41 -0
  45. atomicmemory/providers/mem0/async_provider.py +191 -0
  46. atomicmemory/providers/mem0/config.py +51 -0
  47. atomicmemory/providers/mem0/http.py +195 -0
  48. atomicmemory/providers/mem0/mappers.py +145 -0
  49. atomicmemory/providers/mem0/provider.py +202 -0
  50. atomicmemory/py.typed +0 -0
  51. atomicmemory/search/__init__.py +47 -0
  52. atomicmemory/search/chunking.py +161 -0
  53. atomicmemory/search/ranking.py +94 -0
  54. atomicmemory/search/semantic_search.py +130 -0
  55. atomicmemory/search/similarity.py +110 -0
  56. atomicmemory/storage/__init__.py +63 -0
  57. atomicmemory/storage/_mapping.py +305 -0
  58. atomicmemory/storage/async_client.py +208 -0
  59. atomicmemory/storage/client.py +339 -0
  60. atomicmemory/storage/errors.py +115 -0
  61. atomicmemory/storage/types.py +305 -0
  62. atomicmemory/utils/__init__.py +5 -0
  63. atomicmemory/utils/environment.py +23 -0
  64. atomicmemory-1.0.0.dist-info/METADATA +146 -0
  65. atomicmemory-1.0.0.dist-info/RECORD +67 -0
  66. atomicmemory-1.0.0.dist-info/WHEEL +4 -0
  67. 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)