remember 0.2.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.
- remember/__init__.py +74 -0
- remember/cli.py +113 -0
- remember/client.py +246 -0
- remember/errors.py +76 -0
- remember/models.py +211 -0
- remember/py.typed +0 -0
- remember-0.2.0.dist-info/METADATA +78 -0
- remember-0.2.0.dist-info/RECORD +10 -0
- remember-0.2.0.dist-info/WHEEL +4 -0
- remember-0.2.0.dist-info/entry_points.txt +2 -0
remember/__init__.py
ADDED
|
@@ -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"
|
remember/cli.py
ADDED
|
@@ -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())
|
remember/client.py
ADDED
|
@@ -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
|
remember/errors.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Failures a control-plane call can produce, typed so a caller can branch.
|
|
2
|
+
|
|
3
|
+
D41 binds one error envelope for machine traffic:
|
|
4
|
+
|
|
5
|
+
{"detail": {"code": …, "message": …, "retryable": bool, "request_id": …}}
|
|
6
|
+
|
|
7
|
+
Clients branch on ``code``, never on ``message`` — the message is operator-safe
|
|
8
|
+
prose and explicitly non-authoritative. These exceptions carry the code through
|
|
9
|
+
so a caller can do the same, and expose ``retryable`` rather than making every
|
|
10
|
+
caller re-derive it from a status number.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CloudError(Exception):
|
|
17
|
+
"""Base for every control-plane failure.
|
|
18
|
+
|
|
19
|
+
Carries the D41 envelope fields when the server sent them. A response that
|
|
20
|
+
is not shaped like the envelope still produces one of these — with
|
|
21
|
+
``code=None`` — so a caller never has to handle two failure shapes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
*,
|
|
28
|
+
status_code: int | None = None,
|
|
29
|
+
code: str | None = None,
|
|
30
|
+
retryable: bool = False,
|
|
31
|
+
request_id: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Bind the envelope fields that were present."""
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.code = code
|
|
37
|
+
self.retryable = retryable
|
|
38
|
+
self.request_id = request_id
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
"""Include the request id, because support will ask for it."""
|
|
42
|
+
base = super().__str__()
|
|
43
|
+
return f"{base} (request_id={self.request_id})" if self.request_id else base
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Unauthenticated(CloudError):
|
|
47
|
+
"""No credential, or one the server will not accept (401).
|
|
48
|
+
|
|
49
|
+
Also raised for a revoked or expired credential, and for one whose
|
|
50
|
+
membership has ended — from a client's point of view these are the same
|
|
51
|
+
situation: sign in again and mint a fresh credential.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotPermitted(CloudError):
|
|
56
|
+
"""Authenticated, but this credential may not do this (403).
|
|
57
|
+
|
|
58
|
+
Distinct from :class:`Unauthenticated` because retrying will not help and a
|
|
59
|
+
fresh credential of the same profile will not either — the profile itself
|
|
60
|
+
does not permit the call, or the credential belongs to a different
|
|
61
|
+
organisation.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RateLimited(CloudError):
|
|
66
|
+
"""Admission refused the call (429).
|
|
67
|
+
|
|
68
|
+
``retry_after`` is the server's own advice in seconds when it gave any.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self, message: str, *, retry_after: float | None = None, **kwargs: object
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Bind the retry hint alongside the envelope fields."""
|
|
75
|
+
super().__init__(message, **kwargs) # type: ignore[arg-type]
|
|
76
|
+
self.retry_after = retry_after
|
remember/models.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""What the control plane returns, narrowed to what a caller needs.
|
|
2
|
+
|
|
3
|
+
These are deliberately *not* generated from OpenAPI. The generated client is the
|
|
4
|
+
browser app's contract and changes with it; a published wheel needs a surface
|
|
5
|
+
that stays still. So each model takes the fields this package promises and
|
|
6
|
+
ignores the rest, and an added server field is a non-event rather than a
|
|
7
|
+
breakage.
|
|
8
|
+
|
|
9
|
+
Unknown enum-ish values are kept as strings for the same reason: a new
|
|
10
|
+
deployment state should print, not raise.
|
|
11
|
+
|
|
12
|
+
**The field names here are a contract with the server, not a convention.** They
|
|
13
|
+
were wrong once — this client read ``balance`` and compared the billing state to
|
|
14
|
+
lowercase ``"active"``, while the control plane sends ``balance_credits`` and
|
|
15
|
+
``ACTIVE`` — and every test passed, because the tests asserted the same invented
|
|
16
|
+
shape. ``src/tests/test_remember_client_contract.py`` now feeds real server
|
|
17
|
+
response models through these parsers, so the next such drift fails in CI
|
|
18
|
+
instead of in a user's terminal.
|
|
19
|
+
|
|
20
|
+
Two conventions in the API this file has to absorb, rather than pass on:
|
|
21
|
+
|
|
22
|
+
* the two state enums disagree about case. ``DeploymentState`` is lowercase
|
|
23
|
+
(``active``); ``BillingState`` is uppercase (``ACTIVE``). Comparisons here are
|
|
24
|
+
case-insensitive so a caller never has to know which is which.
|
|
25
|
+
* money arrives as a JSON string (Pydantic renders ``Decimal`` that way) and
|
|
26
|
+
stays a string here. It is displayed and compared, never arithmetic'd by this
|
|
27
|
+
client, and ``float`` would be the wrong type for money.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text(payload: Mapping[str, Any], key: str) -> str | None:
|
|
39
|
+
"""A string field, or None when absent or empty."""
|
|
40
|
+
value = payload.get(key)
|
|
41
|
+
return value if isinstance(value, str) and value else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _money(payload: Mapping[str, Any], *keys: str) -> str | None:
|
|
45
|
+
"""A decimal amount as a string, from the first key that carries one.
|
|
46
|
+
|
|
47
|
+
Accepts a JSON number as well as a string: the server renders ``Decimal`` as
|
|
48
|
+
a string today, and a client that broke if that changed would be brittle for
|
|
49
|
+
no benefit. Several keys may be given where the API has a preferred field
|
|
50
|
+
and a deprecated alias.
|
|
51
|
+
"""
|
|
52
|
+
for key in keys:
|
|
53
|
+
value = payload.get(key)
|
|
54
|
+
if isinstance(value, str) and value:
|
|
55
|
+
return value
|
|
56
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
57
|
+
return str(value)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _moment(payload: Mapping[str, Any], key: str) -> datetime | None:
|
|
62
|
+
"""An ISO-8601 timestamp, or None when absent or unparseable."""
|
|
63
|
+
raw = _text(payload, key)
|
|
64
|
+
if raw is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Deployment:
|
|
74
|
+
"""One deployment's identity and where a client reaches it."""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
state: str
|
|
78
|
+
hostname: str | None
|
|
79
|
+
hostname_live: bool
|
|
80
|
+
created_at: datetime | None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_ready(self) -> bool:
|
|
84
|
+
"""True when this deployment can actually be reached.
|
|
85
|
+
|
|
86
|
+
Both halves are required. ``state == "active"`` is the control plane's
|
|
87
|
+
view of provisioning, but the hostname is a *designed* public name that
|
|
88
|
+
does not resolve to a serving process until the live flag says so
|
|
89
|
+
(D33) — so an active deployment whose flag is false would hand a caller
|
|
90
|
+
an endpoint that refuses connections.
|
|
91
|
+
|
|
92
|
+
The engine's own readiness — whether the memory is warm — is a separate
|
|
93
|
+
question, asked through ``rememberstack``.
|
|
94
|
+
"""
|
|
95
|
+
return self.state.lower() == "active" and self.hostname_live
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> Deployment:
|
|
99
|
+
"""Narrow a deployment response."""
|
|
100
|
+
return cls(
|
|
101
|
+
id=_text(payload, "id") or "",
|
|
102
|
+
state=_text(payload, "state") or "unknown",
|
|
103
|
+
hostname=_text(payload, "data_plane_hostname"),
|
|
104
|
+
hostname_live=bool(payload.get("data_plane_hostname_live", False)),
|
|
105
|
+
created_at=_moment(payload, "created_at"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class BillingStatus:
|
|
111
|
+
"""Whether this organisation may incur chargeable work, and what it has."""
|
|
112
|
+
|
|
113
|
+
state: str
|
|
114
|
+
balance: str | None
|
|
115
|
+
cap: str | None
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def can_spend(self) -> bool:
|
|
119
|
+
"""True when billing is in a state that permits chargeable work.
|
|
120
|
+
|
|
121
|
+
Case-insensitive: ``BillingState`` sends ``ACTIVE``, and a caller
|
|
122
|
+
should not have to know that.
|
|
123
|
+
"""
|
|
124
|
+
return self.state.upper() == "ACTIVE"
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> BillingStatus:
|
|
128
|
+
"""Narrow a billing-status response (``CommercialStatusResponse``)."""
|
|
129
|
+
return cls(
|
|
130
|
+
state=_text(payload, "billing_state") or "unknown",
|
|
131
|
+
balance=_money(payload, "balance_credits"),
|
|
132
|
+
cap=_money(payload, "monthly_cap_credits"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class SpendGate:
|
|
138
|
+
"""The pre-dispatch decision: may work run right now, and if not, why.
|
|
139
|
+
|
|
140
|
+
This is the question an agent should ask before a large ingest, and the one
|
|
141
|
+
that explains a refusal after the fact.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
decision: str
|
|
145
|
+
reason_code: str | None
|
|
146
|
+
spent_usd: str | None
|
|
147
|
+
ceiling_usd: str | None
|
|
148
|
+
parked: bool
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def allows_work(self) -> bool:
|
|
152
|
+
"""True when the gate currently permits dispatch."""
|
|
153
|
+
return self.decision.lower() == "allow"
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def is_parked(self) -> bool:
|
|
157
|
+
"""True when work is paused rather than refused.
|
|
158
|
+
|
|
159
|
+
Parked is recoverable — a cap raise or an operator action releases it —
|
|
160
|
+
where refused means the request will not succeed as sent.
|
|
161
|
+
|
|
162
|
+
Taken from the server's own ``is_parked`` flag rather than inferred from
|
|
163
|
+
the decision: a policy can be parked while the decision reads something
|
|
164
|
+
else, and the server is the authority on which.
|
|
165
|
+
"""
|
|
166
|
+
return self.parked
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> SpendGate:
|
|
170
|
+
"""Narrow a spend-gate response (``DispatchGateResponse``)."""
|
|
171
|
+
decision = _text(payload, "decision") or "unknown"
|
|
172
|
+
return cls(
|
|
173
|
+
decision=decision,
|
|
174
|
+
reason_code=_text(payload, "reason_code"),
|
|
175
|
+
# ``spent_usd`` is the server's deprecated alias for
|
|
176
|
+
# ``estimate_spent_usd``; prefer the current name, accept the old.
|
|
177
|
+
spent_usd=_money(payload, "estimate_spent_usd", "spent_usd"),
|
|
178
|
+
ceiling_usd=_money(payload, "ceiling_usd"),
|
|
179
|
+
parked=bool(payload.get("is_parked", decision.lower() == "park")),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class LedgerEntry:
|
|
185
|
+
"""One append-only credit-ledger line: what was charged, and what remained.
|
|
186
|
+
|
|
187
|
+
The ledger answers "what did that cost me", which D43 expects this
|
|
188
|
+
distribution to be able to show alongside status and balance.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
entry_id: str
|
|
192
|
+
position: int | None
|
|
193
|
+
entry_type: str
|
|
194
|
+
amount: str | None
|
|
195
|
+
balance_after: str | None
|
|
196
|
+
description: str | None
|
|
197
|
+
created_at: datetime | None
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> LedgerEntry:
|
|
201
|
+
"""Narrow a ledger entry (``LedgerEntryResponse``)."""
|
|
202
|
+
position = payload.get("ledger_position")
|
|
203
|
+
return cls(
|
|
204
|
+
entry_id=_text(payload, "credit_entry_id") or "",
|
|
205
|
+
position=position if isinstance(position, int) else None,
|
|
206
|
+
entry_type=_text(payload, "entry_type") or "unknown",
|
|
207
|
+
amount=_money(payload, "amount"),
|
|
208
|
+
balance_after=_money(payload, "balance_after"),
|
|
209
|
+
description=_text(payload, "description"),
|
|
210
|
+
created_at=_moment(payload, "created_at"),
|
|
211
|
+
)
|
remember/py.typed
ADDED
|
File without changes
|
|
@@ -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,10 @@
|
|
|
1
|
+
remember/__init__.py,sha256=v6mMsS-pndNfiQ6PyNI5miatwCMoCiwBQoYoEpGuorU,2617
|
|
2
|
+
remember/cli.py,sha256=ccGGEPce5J382n3MnzHlGIjW9TBBmYfm1gXhjYUo-lc,4266
|
|
3
|
+
remember/client.py,sha256=N9BcXCsgGTuKt-Rqhgw_CZtgxKk6fquL5cp8PJy1Ds8,9050
|
|
4
|
+
remember/errors.py,sha256=r9NqkeuGG8FCKk8w7jpxzGNsov0geGXj0kYdI7xz4pg,2643
|
|
5
|
+
remember/models.py,sha256=x977fyTNdcmwRathe0w5phXOAzBVx8DnlTFjSXHaJSk,7928
|
|
6
|
+
remember/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
remember-0.2.0.dist-info/METADATA,sha256=RP_T6cIRI3TZ22bi0Du-kc9xpCOzJWGsaBh-BE_94bE,2768
|
|
8
|
+
remember-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
remember-0.2.0.dist-info/entry_points.txt,sha256=DscNeNcF7FdWiAJMO-PUEPErgrBVnNZMbqkaZjXiBQM,54
|
|
10
|
+
remember-0.2.0.dist-info/RECORD,,
|