remember 0.1__tar.gz → 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.
@@ -0,0 +1,68 @@
1
+ .loopy_loop/sessions/
2
+ .loopy_loop/traces/
3
+ .loopy_loop/trace_finalization_outbox/
4
+ .loopy_loop/repository.json
5
+ .loopy_loop/state.json
6
+ .loopy_loop/state.json.lock
7
+ .loopy_loop/state.json.archive_*.json
8
+
9
+ # Disposable external research checkouts (never vendored)
10
+ .research/
11
+
12
+ # Python environments and generated caches
13
+ .venv/
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ __pycache__/
17
+ *.py[cod]
18
+
19
+ # IDE state
20
+ .idea/
21
+
22
+ # Cloudflare edge local secrets (never commit tokens)
23
+ ops/cloudflare_edge/config.local.toml
24
+ infra/benchmark-host/config.local.toml
25
+ infra/disposable-dp-host/config.local.toml
26
+
27
+ # Frontend install/build artifacts (source lives in fe/)
28
+ fe/node_modules/
29
+ fe/.next/
30
+ fe/out/
31
+ fe/*.tsbuildinfo
32
+ fe/.pages-deploy.digest
33
+ fe/.agent-browser-shots/
34
+ fe/.pen-exports/
35
+
36
+ # Observability host — secret-bearing / generated deploy artifacts (never commit)
37
+ infra/obs/.env
38
+ infra/obs/purge/purge.env
39
+ infra/obs/purge/projects.env
40
+ infra/obs/vmauth.rendered.yaml
41
+ infra/obs/Caddyfile.rendered
42
+ infra/obs/certs/
43
+
44
+ # Generic secret / rendered shapes (F2 hygiene)
45
+ *.pem
46
+ .env
47
+ *.env
48
+ !*.env.example
49
+ !infra/obs/.env.example
50
+ *.rendered.yaml
51
+ Caddyfile.rendered
52
+ **/umcobs0.conf
53
+
54
+
55
+ fe/storybook-static/
56
+ infra/benchmark-host/config.beam.local.toml
57
+
58
+ # fe_admin (D34)
59
+ fe_admin/node_modules/
60
+ fe_admin/out/
61
+ fe_admin/.next/
62
+ fe_admin/.agent-browser-shots/
63
+ fe_admin/storybook-static/
64
+ .dual-review/
65
+
66
+ # Built client distributions (published via the release workflow, never committed).
67
+ client/dist/
68
+ client/.venv/
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: remember
3
+ Version: 0.2.0
4
+ Summary: Control-plane client for the remember.dev managed memory service.
5
+ Project-URL: Homepage, https://remember.dev
6
+ Project-URL: Documentation, https://remember.dev/docs
7
+ Project-URL: Source, https://github.com/writeitai/ultimate-memory-cloud
8
+ Author-email: "WriteIt.ai s.r.o." <info@writeit.ai>
9
+ License: Apache-2.0
10
+ Keywords: agents,memory,remember.dev,rememberstack
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: rememberstack>=0.8.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # remember
23
+
24
+ Control-plane client for the [remember.dev](https://remember.dev) managed memory service.
25
+
26
+ `rememberstack` answers *memory* questions — ingest a document, search claims, run an assured
27
+ operation. This package answers the questions an operator of that memory also has:
28
+
29
+ - is my deployment ready?
30
+ - what is my balance, and what did that ingest cost?
31
+ - has spend safety parked my work?
32
+
33
+ ```python
34
+ from remember import CloudClient
35
+
36
+ with CloudClient.from_env() as cloud:
37
+ if cloud.is_ready():
38
+ print(cloud.billing_status().balance)
39
+ ```
40
+
41
+ ```console
42
+ $ remember-status
43
+ deployment active bb81063d-6b7b-41d0-a1df-dc57d4f1fe87
44
+ endpoint live bb81063d-6b7b-41d0-a1df-dc57d4f1fe87.dp.remember.dev
45
+ billing active balance 42.10
46
+ spend allow
47
+ ```
48
+
49
+ ## Install
50
+
51
+ ```console
52
+ pip install remember
53
+ ```
54
+
55
+ This installs `rememberstack` too. The `remember` **command** comes from that package and is
56
+ unaffected by this one; this distribution adds only `remember-status`.
57
+
58
+ ## Credentials
59
+
60
+ This client authenticates with a **control-plane token** (`umc_cp_…`) — the D53 credential kind.
61
+ It is not the same thing as a deployment API token (`umc_dp_…`): that one authenticates *memory*
62
+ calls at the deployment's ingress and will be rejected here.
63
+
64
+ Mint one with `POST /v1/orgs/<org>/control-tokens` while signed in to the app. There is no button
65
+ for this yet — the API landed before the interface did — so today it is a call, not a click:
66
+
67
+ ```console
68
+ export REMEMBER_CLOUD_TOKEN='umc_cp_…'
69
+ export REMEMBER_CLOUD_ORG='your-organisation-id'
70
+ ```
71
+
72
+ The secret is shown once. The credential is organisation-scoped, **read-only**, expires (90 days by
73
+ default), and can be surrendered by its holder at any time.
74
+
75
+ ## What it will not do
76
+
77
+ No memory verbs, no second ingest or search contract. To *use* the memory, use `rememberstack`
78
+ pointed at your deployment; this package tells you what that memory costs and whether it is ready.
@@ -0,0 +1,57 @@
1
+ # remember
2
+
3
+ Control-plane client for the [remember.dev](https://remember.dev) managed memory service.
4
+
5
+ `rememberstack` answers *memory* questions — ingest a document, search claims, run an assured
6
+ operation. This package answers the questions an operator of that memory also has:
7
+
8
+ - is my deployment ready?
9
+ - what is my balance, and what did that ingest cost?
10
+ - has spend safety parked my work?
11
+
12
+ ```python
13
+ from remember import CloudClient
14
+
15
+ with CloudClient.from_env() as cloud:
16
+ if cloud.is_ready():
17
+ print(cloud.billing_status().balance)
18
+ ```
19
+
20
+ ```console
21
+ $ remember-status
22
+ deployment active bb81063d-6b7b-41d0-a1df-dc57d4f1fe87
23
+ endpoint live bb81063d-6b7b-41d0-a1df-dc57d4f1fe87.dp.remember.dev
24
+ billing active balance 42.10
25
+ spend allow
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```console
31
+ pip install remember
32
+ ```
33
+
34
+ This installs `rememberstack` too. The `remember` **command** comes from that package and is
35
+ unaffected by this one; this distribution adds only `remember-status`.
36
+
37
+ ## Credentials
38
+
39
+ This client authenticates with a **control-plane token** (`umc_cp_…`) — the D53 credential kind.
40
+ It is not the same thing as a deployment API token (`umc_dp_…`): that one authenticates *memory*
41
+ calls at the deployment's ingress and will be rejected here.
42
+
43
+ Mint one with `POST /v1/orgs/<org>/control-tokens` while signed in to the app. There is no button
44
+ for this yet — the API landed before the interface did — so today it is a call, not a click:
45
+
46
+ ```console
47
+ export REMEMBER_CLOUD_TOKEN='umc_cp_…'
48
+ export REMEMBER_CLOUD_ORG='your-organisation-id'
49
+ ```
50
+
51
+ The secret is shown once. The credential is organisation-scoped, **read-only**, expires (90 days by
52
+ default), and can be surrendered by its holder at any time.
53
+
54
+ ## What it will not do
55
+
56
+ No memory verbs, no second ingest or search contract. To *use* the memory, use `rememberstack`
57
+ pointed at your deployment; this package tells you what that memory costs and whether it is ready.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "remember"
7
+ version = "0.2.0"
8
+ description = "Control-plane client for the remember.dev managed memory service."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "WriteIt.ai s.r.o.", email = "info@writeit.ai" }]
13
+ keywords = ["remember.dev", "rememberstack", "memory", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ # D43 rule 1: the cloud distribution may depend on the memory client; never the
24
+ # reverse. The memory verbs stay in rememberstack — this package adds only the
25
+ # control-plane questions that engine has no business answering.
26
+ dependencies = [
27
+ "httpx>=0.27",
28
+ "rememberstack>=0.8.1",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://remember.dev"
33
+ Documentation = "https://remember.dev/docs"
34
+ Source = "https://github.com/writeitai/ultimate-memory-cloud"
35
+
36
+ # D43 rule 3: this project declares NO console script named `remember`. That
37
+ # entry point belongs to `rememberstack` alone, and colliding on it would be a
38
+ # PATH conflict between two installed distributions.
39
+ [project.scripts]
40
+ remember-status = "remember.cli:main"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/remember"]
44
+
45
+ # The client's tests are their own suite: the repository's root pytest run is
46
+ # scoped to ``src/tests`` and never collects them. Declaring the section here
47
+ # also makes ``client`` pytest's rootdir, so a run does not inherit the service's
48
+ # asyncio settings.
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+
52
+ [dependency-groups]
53
+ dev = ["pytest>=8.3", "respx>=0.21", "mypy>=1.14", "ruff>=0.9"]
@@ -0,0 +1,74 @@
1
+ """Control-plane client for the remember.dev managed service.
2
+
3
+ PyPI project **`remember`**, import name **`remember`** (D43 left the import name
4
+ open and required the first publish to document it; this is that record). The
5
+ distribution installs **no** ``remember`` console script — that entry point
6
+ belongs to ``rememberstack`` alone, and two distributions competing for one name
7
+ on ``PATH`` is the collision D43 rule 3 forbids.
8
+
9
+ What this package is for
10
+ ------------------------
11
+
12
+ ``rememberstack`` answers *memory* questions: ingest a document, search claims,
13
+ run an assured operation. It cannot answer the questions an operator of that
14
+ memory also has —
15
+
16
+ * is my deployment ready?
17
+ * what is my balance, and what did that ingest cost?
18
+ * has spend safety parked my work?
19
+
20
+ Those live on the control plane, which until D53 accepted only a browser session
21
+ cookie. This client speaks to it with a **control-plane token** (``umc_cp_…``):
22
+ organisation-scoped, read-only, bounded lifetime, revocable by its bearer.
23
+
24
+ from remember import CloudClient
25
+
26
+ with CloudClient.from_env() as cloud:
27
+ status = cloud.billing_status()
28
+ print(status.state, status.balance)
29
+
30
+ Getting a credential
31
+ --------------------
32
+
33
+ A control-plane token is not a deployment API token: `umc_dp_…` authenticates
34
+ *memory* calls at a deployment's ingress and is rejected here. Mint one with
35
+ `POST /v1/orgs/<org>/control-tokens` while signed in to the app — there is no
36
+ button for it yet, the API landed before the interface did. Then:
37
+
38
+ export REMEMBER_CLOUD_TOKEN='umc_cp_…'
39
+ export REMEMBER_CLOUD_ORG='your-organisation-id'
40
+
41
+ The secret is shown once. `remember login` does not yet mint this kind — that
42
+ needs an amendment to the device grant (D40), tracked in D53 §6.
43
+
44
+ What it deliberately does not do
45
+ --------------------------------
46
+
47
+ No memory verbs. No second ingest, search, or envelope contract (D35). If you
48
+ want to *use* the memory, use ``rememberstack``; this package tells you what the
49
+ memory costs and whether it is ready.
50
+ """
51
+
52
+ from remember.client import CloudClient
53
+ from remember.errors import CloudError
54
+ from remember.errors import NotPermitted
55
+ from remember.errors import RateLimited
56
+ from remember.errors import Unauthenticated
57
+ from remember.models import BillingStatus
58
+ from remember.models import Deployment
59
+ from remember.models import LedgerEntry
60
+ from remember.models import SpendGate
61
+
62
+ __all__ = [
63
+ "BillingStatus",
64
+ "CloudClient",
65
+ "CloudError",
66
+ "Deployment",
67
+ "LedgerEntry",
68
+ "NotPermitted",
69
+ "RateLimited",
70
+ "SpendGate",
71
+ "Unauthenticated",
72
+ ]
73
+
74
+ __version__ = "0.2.0"
@@ -0,0 +1,113 @@
1
+ """``remember-status`` — one command that answers the operator's question.
2
+
3
+ Named for what it does, and deliberately **not** ``remember``: that console
4
+ script belongs to ``rememberstack`` alone (D43 rule 3), and two installed
5
+ distributions competing for one name on ``PATH`` is a conflict no user should
6
+ have to debug.
7
+
8
+ $ remember-status
9
+ deployment active bb81063d-…dc57d4f1fe87
10
+ endpoint live bb81063d-….dp.remember.dev
11
+ billing active balance 42.10
12
+ spend allow
13
+
14
+ Exit status is meaningful, so a shell can branch: ``0`` ready, ``1`` not ready,
15
+ ``2`` could not ask.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ from collections.abc import Sequence
22
+ import sys
23
+
24
+ from remember.client import CloudClient
25
+ from remember.errors import CloudError
26
+ from remember.errors import RateLimited
27
+ from remember.errors import Unauthenticated
28
+
29
+
30
+ def main(argv: Sequence[str] | None = None) -> int:
31
+ """Print one organisation's status; return an exit code a script can use."""
32
+ parser = argparse.ArgumentParser(
33
+ prog="remember-status",
34
+ description=(
35
+ "Report a remember.dev organisation's deployment, billing, and "
36
+ "spend state. Reads REMEMBER_CLOUD_TOKEN and REMEMBER_CLOUD_ORG."
37
+ ),
38
+ )
39
+ parser.add_argument("--org", default=None, help="organisation id")
40
+ parser.add_argument("--url", default=None, help="control-plane base URL")
41
+ parser.add_argument(
42
+ "--quiet", action="store_true", help="print nothing; use the exit status only"
43
+ )
44
+ args = parser.parse_args(argv)
45
+
46
+ try:
47
+ overrides = {}
48
+ if args.org:
49
+ overrides["org_id"] = args.org
50
+ if args.url:
51
+ overrides["base_url"] = args.url
52
+ with CloudClient.from_env(**overrides) as cloud:
53
+ return _report(cloud=cloud, quiet=bool(args.quiet))
54
+ except ValueError as error:
55
+ # Missing configuration: tell the user what to set, not a stack trace.
56
+ print(f"remember-status: {error}", file=sys.stderr)
57
+ return 2
58
+ except Unauthenticated as error:
59
+ print(
60
+ f"remember-status: credential rejected ({error}). "
61
+ "Mint a fresh control-plane token with "
62
+ "POST /v1/orgs/<org>/control-tokens while signed in.",
63
+ file=sys.stderr,
64
+ )
65
+ return 2
66
+ except RateLimited as error:
67
+ wait = f" retry in {error.retry_after:.0f}s" if error.retry_after else ""
68
+ print(f"remember-status: rate limited{wait}", file=sys.stderr)
69
+ return 2
70
+ except CloudError as error:
71
+ print(f"remember-status: {error}", file=sys.stderr)
72
+ return 2
73
+
74
+
75
+ def _report(*, cloud: CloudClient, quiet: bool) -> int:
76
+ """Print the four facts worth knowing, and decide the exit code."""
77
+ deployment = cloud.deployment()
78
+ billing = cloud.billing_status()
79
+ gate = None
80
+ if deployment is not None:
81
+ gate = cloud.spend_gate(deployment_id=deployment.id)
82
+
83
+ if not quiet:
84
+ if deployment is None:
85
+ _line("deployment", "none", "no deployment provisioned yet")
86
+ else:
87
+ _line("deployment", deployment.state, deployment.id)
88
+ _line(
89
+ "endpoint",
90
+ "live" if deployment.hostname_live else "not serving",
91
+ deployment.hostname or "unknown",
92
+ )
93
+ balance = f"balance {billing.balance}" if billing.balance else ""
94
+ _line("billing", billing.state, balance)
95
+ if gate is not None:
96
+ _line("spend", gate.decision, gate.reason_code or "")
97
+
98
+ # Every fact printed above counts toward the exit code. A script that
99
+ # branches on `remember-status` is asking "can work run right now", and a
100
+ # zero exit while the spend gate says `park` would send it straight into a
101
+ # refusal. `is_ready` already covers endpoint liveness as well as state.
102
+ ready = (
103
+ deployment is not None
104
+ and deployment.is_ready
105
+ and billing.can_spend
106
+ and (gate is None or gate.allows_work)
107
+ )
108
+ return 0 if ready else 1
109
+
110
+
111
+ def _line(label: str, state: str, detail: str = "") -> None:
112
+ """One aligned row, so several runs stack readably in a terminal."""
113
+ print(f"{label:<11} {state:<10} {detail}".rstrip())
@@ -0,0 +1,246 @@
1
+ """The control-plane client.
2
+
3
+ One class, a handful of read methods, and no memory verbs. Everything it can do
4
+ is what D53's ``status:read`` profile permits — which is deliberate: a credential
5
+ that could do more would be a credential worth stealing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from collections.abc import Mapping
12
+ from contextlib import contextmanager
13
+ import os
14
+ from types import TracebackType
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from remember.errors import CloudError
20
+ from remember.errors import NotPermitted
21
+ from remember.errors import RateLimited
22
+ from remember.errors import Unauthenticated
23
+ from remember.models import BillingStatus
24
+ from remember.models import Deployment
25
+ from remember.models import LedgerEntry
26
+ from remember.models import SpendGate
27
+
28
+ #: Where the managed control plane lives. Overridable for local dogfood.
29
+ DEFAULT_BASE_URL = "https://remember.dev/app/api"
30
+
31
+ #: Environment variables, named so they cannot be confused with the memory
32
+ #: client's ``REMEMBERSTACK_*`` pair — a machine often holds both.
33
+ TOKEN_ENV = "REMEMBER_CLOUD_TOKEN"
34
+ ORG_ENV = "REMEMBER_CLOUD_ORG"
35
+ BASE_URL_ENV = "REMEMBER_CLOUD_URL"
36
+
37
+
38
+ class CloudClient:
39
+ """Ask the control plane what it knows about one organisation.
40
+
41
+ The credential is organisation-bound, so the organisation is fixed for the
42
+ life of the client rather than passed per call: a control token cannot act
43
+ on another organisation, and an API that invited you to try would be
44
+ misleading.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ token: str,
51
+ org_id: str,
52
+ base_url: str = DEFAULT_BASE_URL,
53
+ timeout: float = 30.0,
54
+ transport: httpx.BaseTransport | None = None,
55
+ ) -> None:
56
+ """Bind a credential to one organisation."""
57
+ if not token:
58
+ raise ValueError("a control-plane token is required")
59
+ if not org_id:
60
+ raise ValueError("an organisation id is required")
61
+ self._org_id = org_id
62
+ self._http = httpx.Client(
63
+ base_url=base_url.rstrip("/"),
64
+ timeout=timeout,
65
+ transport=transport,
66
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
67
+ )
68
+
69
+ @classmethod
70
+ def from_env(cls, **overrides: Any) -> "CloudClient":
71
+ """Build from ``REMEMBER_CLOUD_TOKEN`` / ``_ORG`` / ``_URL``.
72
+
73
+ The usual shape for an agent: credentials in the environment, nothing in
74
+ the code.
75
+ """
76
+ token = overrides.pop("token", None) or os.getenv(TOKEN_ENV, "")
77
+ org_id = overrides.pop("org_id", None) or os.getenv(ORG_ENV, "")
78
+ base_url = (
79
+ overrides.pop("base_url", None)
80
+ or os.getenv(BASE_URL_ENV)
81
+ or DEFAULT_BASE_URL
82
+ )
83
+ if not token:
84
+ raise ValueError(
85
+ f"set {TOKEN_ENV} to a control-plane token (umc_cp_…). "
86
+ "Mint one with POST /v1/orgs/<org>/control-tokens while signed "
87
+ "in; a deployment token (umc_dp_…) is a different credential "
88
+ "and the control plane rejects it"
89
+ )
90
+ if not org_id:
91
+ raise ValueError(f"set {ORG_ENV} to your organisation id")
92
+ return cls(token=token, org_id=org_id, base_url=base_url, **overrides)
93
+
94
+ @property
95
+ def org_id(self) -> str:
96
+ """The organisation this credential is bound to."""
97
+ return self._org_id
98
+
99
+ # -- the questions -------------------------------------------------
100
+
101
+ def billing_status(self) -> BillingStatus:
102
+ """Whether chargeable work may run, and what the balance is."""
103
+ return BillingStatus.from_payload(
104
+ self._get(f"/v1/orgs/{self._org_id}/billing/status")
105
+ )
106
+
107
+ def deployments(self) -> list[Deployment]:
108
+ """Every deployment this organisation has (today, zero or one)."""
109
+ payload = self._get(f"/v1/orgs/{self._org_id}/deployments")
110
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
111
+ return [Deployment.from_payload(row) for row in rows]
112
+
113
+ def deployment(self) -> Deployment | None:
114
+ """The organisation's deployment, or None before one is provisioned."""
115
+ found = self.deployments()
116
+ return found[0] if found else None
117
+
118
+ def ledger(self, *, limit: int = 50) -> list[LedgerEntry]:
119
+ """The credit ledger: what was charged, newest first as the server sends.
120
+
121
+ ``limit`` is bounded by the server to 1..200; values outside that range
122
+ are rejected there rather than silently clamped here, so a caller sees
123
+ its own mistake.
124
+ """
125
+ payload = self._get(
126
+ f"/v1/orgs/{self._org_id}/billing/ledger", params={"limit": limit}
127
+ )
128
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
129
+ return [LedgerEntry.from_payload(row) for row in rows]
130
+
131
+ def spend_gate(self, *, deployment_id: str) -> SpendGate:
132
+ """May work dispatch right now — and if not, why.
133
+
134
+ Worth asking before a large ingest: a refusal here is cheaper than a
135
+ refusal halfway through one.
136
+ """
137
+ return SpendGate.from_payload(
138
+ self._get(
139
+ f"/v1/orgs/{self._org_id}/deployments/{deployment_id}/spend-safety/gate"
140
+ )
141
+ )
142
+
143
+ def is_ready(self) -> bool:
144
+ """One call an agent can branch on: is there a deployment able to serve.
145
+
146
+ Convenience over :meth:`deployment`, because "am I ready" is the
147
+ question actually being asked.
148
+ """
149
+ found = self.deployment()
150
+ return found is not None and found.is_ready
151
+
152
+ # -- plumbing ------------------------------------------------------
153
+
154
+ def _get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
155
+ """Perform a read, translating D41 error envelopes into exceptions."""
156
+ try:
157
+ response = self._http.get(path, params=params)
158
+ except httpx.TimeoutException as error:
159
+ raise CloudError(f"timed out calling {path}", retryable=True) from error
160
+ except httpx.HTTPError as error:
161
+ raise CloudError(f"could not reach {path}: {error}") from error
162
+
163
+ if response.is_success:
164
+ return response.json()
165
+ raise _as_error(response)
166
+
167
+ def close(self) -> None:
168
+ """Release the underlying connection pool."""
169
+ self._http.close()
170
+
171
+ def __enter__(self) -> "CloudClient":
172
+ """Support ``with CloudClient(...) as cloud:``."""
173
+ return self
174
+
175
+ def __exit__(
176
+ self,
177
+ exc_type: type[BaseException] | None,
178
+ exc: BaseException | None,
179
+ tb: TracebackType | None,
180
+ ) -> None:
181
+ """Close on exit."""
182
+ self.close()
183
+
184
+
185
+ def _as_error(response: httpx.Response) -> CloudError:
186
+ """Turn a non-success response into the narrowest exception that fits.
187
+
188
+ D41's envelope is ``{"detail": {code, message, retryable, request_id}}``. A
189
+ response that does not carry it — a proxy error page, say — still produces a
190
+ typed exception, so a caller never has to handle two failure shapes.
191
+ """
192
+ code: str | None = None
193
+ message = f"HTTP {response.status_code}"
194
+ retryable = False
195
+ request_id = response.headers.get("X-Request-Id")
196
+
197
+ with _tolerating_bad_json():
198
+ body = response.json()
199
+ detail = body.get("detail") if isinstance(body, dict) else None
200
+ if isinstance(detail, dict):
201
+ code = detail.get("code")
202
+ message = detail.get("message") or message
203
+ retryable = bool(detail.get("retryable", False))
204
+ request_id = detail.get("request_id") or request_id
205
+ elif isinstance(detail, str):
206
+ # Pre-D41 routes still answer with a bare string.
207
+ message = detail
208
+
209
+ shared = {
210
+ "status_code": response.status_code,
211
+ "code": code,
212
+ "retryable": retryable,
213
+ "request_id": request_id,
214
+ }
215
+ if response.status_code == 401:
216
+ return Unauthenticated(message, **shared) # type: ignore[arg-type]
217
+ if response.status_code == 403:
218
+ return NotPermitted(message, **shared) # type: ignore[arg-type]
219
+ if response.status_code == 429:
220
+ return RateLimited(
221
+ message,
222
+ retry_after=_retry_after(response),
223
+ **shared, # type: ignore[arg-type]
224
+ )
225
+ return CloudError(message, **shared) # type: ignore[arg-type]
226
+
227
+
228
+ def _retry_after(response: httpx.Response) -> float | None:
229
+ """Seconds from ``Retry-After``, when the server sent a usable one."""
230
+ raw = response.headers.get("Retry-After")
231
+ if not raw:
232
+ return None
233
+ try:
234
+ return float(raw)
235
+ except ValueError:
236
+ # HTTP-date form; the caller's own backoff is better than a bad guess.
237
+ return None
238
+
239
+
240
+ @contextmanager
241
+ def _tolerating_bad_json() -> Iterator[None]:
242
+ """Ignore an unparseable error body rather than masking the real failure."""
243
+ try:
244
+ yield
245
+ except (ValueError, AttributeError):
246
+ return