kaibo 0.1.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.
kaibo/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """kaibo: memory for AI agents. Four operations: remember, recall, explain, forget."""
2
+
3
+ from ._core import SDK_VERSION
4
+ from .async_client import AsyncKaibo
5
+ from .client import Kaibo
6
+ from .errors import ApiError, KaiboError, NetworkError, RateLimitError, TimeoutError, WebhookSignatureError
7
+ from .render import render_context
8
+ from .webhooks import verify_webhook
9
+
10
+ __version__ = SDK_VERSION
11
+ __all__ = [
12
+ "ApiError",
13
+ "AsyncKaibo",
14
+ "Kaibo",
15
+ "KaiboError",
16
+ "NetworkError",
17
+ "RateLimitError",
18
+ "TimeoutError",
19
+ "WebhookSignatureError",
20
+ "render_context",
21
+ "verify_webhook",
22
+ ]
kaibo/_core.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import Any, Iterable, Mapping, Optional
8
+
9
+ import httpx
10
+
11
+ from .errors import ApiError, KaiboError, RateLimitError
12
+
13
+ SDK_VERSION = "0.1.0"
14
+
15
+
16
+ def env_default(name: str) -> Optional[str]:
17
+ """An environment variable, or None when unset or blank."""
18
+ import os
19
+
20
+ v = os.environ.get(name, "").strip()
21
+ return v or None
22
+ RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
23
+
24
+
25
+ def backoff_seconds(attempt: int, rand: float, base: float = 0.25, cap: float = 8.0) -> float:
26
+ """Exponential backoff with full jitter: uniform in [0, min(cap, base * 2**attempt)]."""
27
+ return float(rand * min(cap, base * (2**attempt)))
28
+
29
+
30
+ def new_idempotency_key() -> str:
31
+ return f"sdk-{uuid.uuid4()}"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Prepared:
36
+ method: str
37
+ url: str
38
+ headers: dict[str, str]
39
+ json: Optional[Any]
40
+
41
+
42
+ class Config:
43
+ def __init__(
44
+ self,
45
+ api_key: str,
46
+ base_url: str,
47
+ project: Optional[str],
48
+ retries: int,
49
+ timeout: float,
50
+ ) -> None:
51
+ if not api_key:
52
+ raise KaiboError(
53
+ "no api key. Pass api_key=... or set KAIBO_KEY "
54
+ "(keys look like kaibo_<id>.<secret>; create one in the dashboard, under api keys)."
55
+ )
56
+ if not base_url:
57
+ raise KaiboError(
58
+ "no address. Pass base_url=... or set KAIBO_URL to the address you open "
59
+ "the kaibo dashboard at (for example http://localhost:3000)."
60
+ )
61
+ self.api_key = api_key
62
+ self.base_url = base_url.rstrip("/")
63
+ self.project = project
64
+ self.retries = retries
65
+ self.timeout = timeout
66
+
67
+ def prepare(
68
+ self,
69
+ method: str,
70
+ path: str,
71
+ *,
72
+ query: Optional[Mapping[str, Any]] = None,
73
+ body: Optional[Any] = None,
74
+ accept: str = "application/json",
75
+ ) -> Prepared:
76
+ q = {k: str(v) for k, v in (query or {}).items() if v is not None}
77
+ url = f"{self.base_url}{path}"
78
+ if q:
79
+ url += "?" + str(httpx.QueryParams(q))
80
+ headers = {
81
+ "accept": accept,
82
+ "authorization": f"Bearer {self.api_key}",
83
+ "x-sdk": f"kaibo-sdk-py/{SDK_VERSION}",
84
+ }
85
+ if self.project:
86
+ headers["x-project-id"] = self.project
87
+ if body is not None:
88
+ headers["content-type"] = "application/json"
89
+ return Prepared(method, url, headers, body)
90
+
91
+
92
+ def api_error(res: httpx.Response) -> ApiError:
93
+ code, message = "unknown", f"request failed ({res.status_code})"
94
+ request_id = res.headers.get("x-request-id")
95
+ try:
96
+ err = res.json().get("error")
97
+ if isinstance(err, dict):
98
+ code = str(err.get("code", code))
99
+ message = str(err.get("message", message))
100
+ request_id = err.get("requestId", request_id)
101
+ except Exception: # non-JSON error body
102
+ pass
103
+ if res.status_code == 429:
104
+ try:
105
+ ra = float(res.headers.get("retry-after", ""))
106
+ except ValueError:
107
+ ra = 0.0
108
+ return RateLimitError(message, request_id=request_id, retry_after=ra if ra > 0 else None)
109
+ return ApiError(message, code=code, status=res.status_code, request_id=request_id)
110
+
111
+
112
+ def retry_wait(err: ApiError, attempt: int, rand: float) -> float:
113
+ if isinstance(err, RateLimitError) and err.retry_after is not None:
114
+ return err.retry_after
115
+ return backoff_seconds(attempt, rand)
116
+
117
+
118
+ def jitter() -> float:
119
+ return random.random()
120
+
121
+
122
+ def parse_sse(lines: Iterable[str]) -> Iterable[tuple[str, str]]:
123
+ """Yields (event, data) for each server-sent event in a stream of text lines."""
124
+ event: str = "message"
125
+ data: list[str] = []
126
+ for line in lines:
127
+ if line == "":
128
+ if data:
129
+ yield event, "\n".join(data)
130
+ event, data = "message", []
131
+ elif line.startswith(":"):
132
+ continue
133
+ elif line.startswith("event:"):
134
+ event = line[6:].strip()
135
+ elif line.startswith("data:"):
136
+ data.append(line[5:].lstrip(" "))
137
+ if data:
138
+ yield event, "\n".join(data)
139
+
140
+
141
+ def loads(text: str) -> Any:
142
+ return json.loads(text)
kaibo/async_client.py ADDED
@@ -0,0 +1,453 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Union
6
+
7
+ import httpx
8
+
9
+ from ._core import (
10
+ RETRYABLE_STATUS,
11
+ env_default,
12
+ Config,
13
+ Prepared,
14
+ api_error,
15
+ jitter,
16
+ loads,
17
+ parse_sse,
18
+ retry_wait,
19
+ )
20
+ from .client import (
21
+ _RECALL_EVENTS,
22
+ _export_body,
23
+ _export_record,
24
+ _forget_body,
25
+ _import_body,
26
+ _key_body,
27
+ _q,
28
+ _recall_body,
29
+ _remember_body,
30
+ )
31
+ from .render import render_context
32
+ from .errors import ApiError, KaiboError, NetworkError, TimeoutError
33
+ from .models import (
34
+ ApiKey,
35
+ MemoryRef,
36
+ Project,
37
+ ContextPack,
38
+ CreatedApiKey,
39
+ CreatedWebhook,
40
+ DeletionReceipt,
41
+ Explanation,
42
+ ExportRecord,
43
+ ForgetDryRun,
44
+ ImportResult,
45
+ ImportSource,
46
+ JobStatus,
47
+ KeyScope,
48
+ Memory,
49
+ MemoryStatus,
50
+ MemoryType,
51
+ RecallEvent,
52
+ ReceiptVerification,
53
+ RememberResult,
54
+ StoredEvent,
55
+ Usage,
56
+ Webhook,
57
+ WebhookDelivery,
58
+ WebhookEventType,
59
+ WhoAmI,
60
+ )
61
+
62
+ ASleep = Callable[[float], Awaitable[None]]
63
+
64
+
65
+ class AsyncKaibo:
66
+ """asyncio client with the same surface as `Kaibo`.
67
+
68
+ >>> async with AsyncKaibo(api_key=os.environ["KAIBO_KEY"]) as kaibo:
69
+ ... await kaibo.remember(text="We ship on July 17.", wait=True)
70
+ ... pack = await kaibo.recall("ship date")
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ api_key: Optional[str] = None,
76
+ *,
77
+ base_url: Optional[str] = None,
78
+ project: Optional[str] = None,
79
+ retries: int = 2,
80
+ timeout: float = 30.0,
81
+ http_client: Optional[httpx.AsyncClient] = None,
82
+ sleep: ASleep = asyncio.sleep,
83
+ ) -> None:
84
+ self._cfg = Config(
85
+ api_key or env_default("KAIBO_KEY") or "",
86
+ base_url or env_default("KAIBO_URL") or "",
87
+ project or env_default("KAIBO_PROJECT"),
88
+ retries,
89
+ timeout,
90
+ )
91
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
92
+ self._owns_http = http_client is None
93
+ self._sleep = sleep
94
+ self.events = _AEvents(self)
95
+ self.memories = _AMemories(self)
96
+ self.jobs = _AJobs(self)
97
+ self.webhooks = _AWebhooks(self)
98
+ self.imports = _AImports(self)
99
+ self.exports = _AExports(self)
100
+ self.keys = _AKeys(self)
101
+ self.projects = _AProjects(self)
102
+
103
+ async def aclose(self) -> None:
104
+ if self._owns_http:
105
+ await self._http.aclose()
106
+
107
+ async def __aenter__(self) -> "AsyncKaibo":
108
+ return self
109
+
110
+ async def __aexit__(self, *_: object) -> None:
111
+ await self.aclose()
112
+
113
+ async def _send(self, p: Prepared, *, retry: bool, stream: bool = False) -> httpx.Response:
114
+ attempts = (self._cfg.retries if retry else 0) + 1
115
+ last: Optional[Exception] = None
116
+ for attempt in range(attempts):
117
+ try:
118
+ req = self._http.build_request(
119
+ p.method, p.url, headers=p.headers, json=p.json, timeout=self._cfg.timeout
120
+ )
121
+ res = await self._http.send(req, stream=stream)
122
+ if res.is_success:
123
+ return res
124
+ if stream:
125
+ await res.aread()
126
+ err = api_error(res)
127
+ await res.aclose()
128
+ if res.status_code in RETRYABLE_STATUS and attempt < attempts - 1:
129
+ last = err
130
+ await self._sleep(retry_wait(err, attempt, jitter()))
131
+ continue
132
+ raise err
133
+ except ApiError:
134
+ raise
135
+ except httpx.TimeoutException as e:
136
+ last = TimeoutError(f"request timed out after {self._cfg.timeout}s")
137
+ last.__cause__ = e
138
+ except httpx.HTTPError as e:
139
+ last = NetworkError("network request failed")
140
+ last.__cause__ = e
141
+ if attempt >= attempts - 1:
142
+ raise last
143
+ await self._sleep(retry_wait(ApiError("", code="x", status=0, request_id=None), attempt, jitter()))
144
+ raise last or KaiboError("request failed")
145
+
146
+ async def _request(self, method: str, path: str, *, retry: bool, query: Any = None, body: Any = None) -> Any:
147
+ res = await self._send(self._cfg.prepare(method, path, query=query, body=body), retry=retry)
148
+ return None if res.status_code == 204 else res.json()
149
+
150
+ async def remember(
151
+ self,
152
+ text: Optional[str] = None,
153
+ *,
154
+ events: Optional[list[dict[str, Any]]] = None,
155
+ stream: Optional[str] = None,
156
+ idempotency_key: Optional[str] = None,
157
+ wait: Union[bool, float] = False,
158
+ ) -> RememberResult:
159
+ body = _remember_body(text, events, stream, idempotency_key)
160
+ result = RememberResult.model_validate(await self._request("POST", "/v1/remember", body=body, retry=True))
161
+ if wait:
162
+ await self.jobs.wait(result.job_id, timeout=10.0 if wait is True else float(wait))
163
+ return result
164
+
165
+ async def recall(
166
+ self,
167
+ query: str,
168
+ *,
169
+ as_of: Optional[str] = None,
170
+ budget_tokens: Optional[int] = None,
171
+ types: Optional[Sequence[MemoryType]] = None,
172
+ scope: Optional[str] = None,
173
+ mode: Optional[str] = None,
174
+ min_trust: Optional[str] = None,
175
+ ) -> ContextPack:
176
+ body = _recall_body(query, as_of, budget_tokens, types, scope, mode, min_trust)
177
+ return ContextPack.model_validate(await self._request("POST", "/v1/recall", body=body, retry=True))
178
+
179
+ async def context(self, query: str, **options: Any) -> str:
180
+ """Recall, rendered as text for a prompt (see `Kaibo.context`)."""
181
+ return render_context(await self.recall(query, **options))
182
+
183
+ async def verify_pack(self, pack: ContextPack) -> dict[str, Any]:
184
+ body = {"pack": pack.model_dump(by_alias=True, mode="json", exclude_none=True)}
185
+ return await self._request("POST", "/v1/attest/verify", body=body, retry=True)
186
+
187
+ async def diff(self, from_: str, to: str) -> dict[str, Any]:
188
+ return await self._request("POST", "/v1/diff", body={"from": from_, "to": to}, retry=True)
189
+
190
+ async def verify_memory(self, memory_id: MemoryRef) -> dict[str, Any]:
191
+ return await self._request("POST", f"/v1/memories/{_q(memory_id)}/verify", retry=False)
192
+
193
+ async def recall_stream(
194
+ self,
195
+ query: str,
196
+ *,
197
+ as_of: Optional[str] = None,
198
+ budget_tokens: Optional[int] = None,
199
+ types: Optional[Sequence[MemoryType]] = None,
200
+ scope: Optional[str] = None,
201
+ ) -> AsyncIterator[RecallEvent]:
202
+ body = _recall_body(query, as_of, budget_tokens, types, scope)
203
+ p = self._cfg.prepare("POST", "/v1/recall", body=body, accept="text/event-stream")
204
+ res = await self._send(p, retry=False, stream=True)
205
+ try:
206
+ lines: list[str] = []
207
+ async for line in res.aiter_lines():
208
+ lines.append(line)
209
+ if line == "":
210
+ for name, data in parse_sse(lines):
211
+ model = _RECALL_EVENTS.get(name)
212
+ if model:
213
+ yield model.model_validate(loads(data))
214
+ lines = []
215
+ finally:
216
+ await res.aclose()
217
+
218
+ async def explain(self, memory_id: MemoryRef, *, depth: Optional[int] = None) -> Explanation:
219
+ return Explanation.model_validate(
220
+ await self._request("GET", f"/v1/explain/{_q(memory_id)}", query={"depth": depth}, retry=True)
221
+ )
222
+
223
+ async def forget(
224
+ self, *, memory_id: Optional[str] = None, query: Optional[str] = None, reason: str
225
+ ) -> DeletionReceipt:
226
+ body = _forget_body(memory_id, query, reason, False)
227
+ out = await self._request("POST", "/v1/forget", body=body, retry=False)
228
+ return DeletionReceipt.model_validate(out["receipt"])
229
+
230
+ async def forget_dry_run(
231
+ self, *, memory_id: Optional[str] = None, query: Optional[str] = None, reason: str
232
+ ) -> ForgetDryRun:
233
+ body = _forget_body(memory_id, query, reason, True)
234
+ return ForgetDryRun.model_validate(await self._request("POST", "/v1/forget", body=body, retry=False))
235
+
236
+ async def verify_receipt(self, receipt_id: str) -> ReceiptVerification:
237
+ return ReceiptVerification.model_validate(
238
+ await self._request("GET", f"/v1/forget/receipts/{_q(receipt_id)}/verify", retry=True)
239
+ )
240
+
241
+ async def get_receipt(self, receipt_id: str) -> DeletionReceipt:
242
+ return DeletionReceipt.model_validate(
243
+ await self._request("GET", f"/v1/forget/receipts/{_q(receipt_id)}", retry=True)
244
+ )
245
+
246
+ async def whoami(self) -> WhoAmI:
247
+ return WhoAmI.model_validate(await self._request("GET", "/v1/whoami", retry=True))
248
+
249
+ async def usage(self) -> Usage:
250
+ return Usage.model_validate(await self._request("GET", "/v1/usage", retry=True))
251
+
252
+
253
+ class _AEvents:
254
+ def __init__(self, c: AsyncKaibo) -> None:
255
+ self._c = c
256
+
257
+ async def get(self, event_id: str) -> StoredEvent:
258
+ return StoredEvent.model_validate(await self._c._request("GET", f"/v1/events/{_q(event_id)}", retry=True))
259
+
260
+ async def page(
261
+ self, *, stream: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None
262
+ ) -> tuple[list[StoredEvent], Optional[str]]:
263
+ out = await self._c._request(
264
+ "GET", "/v1/events", query={"stream": stream, "limit": limit, "cursor": cursor}, retry=True
265
+ )
266
+ return [StoredEvent.model_validate(e) for e in out["items"]], out["nextCursor"]
267
+
268
+ async def iter(self, *, stream: str = "default", page_size: int = 100) -> AsyncIterator[StoredEvent]:
269
+ cursor: Optional[str] = None
270
+ while True:
271
+ items, cursor = await self.page(stream=stream, limit=page_size, cursor=cursor)
272
+ for e in items:
273
+ yield e
274
+ if not cursor:
275
+ return
276
+
277
+
278
+ class _AMemories:
279
+ def __init__(self, c: AsyncKaibo) -> None:
280
+ self._c = c
281
+
282
+ async def get(self, memory_id: MemoryRef) -> Memory:
283
+ return Memory.model_validate(await self._c._request("GET", f"/v1/memories/{_q(memory_id)}", retry=True))
284
+
285
+ async def edit(self, memory_id: MemoryRef, content: str) -> Memory:
286
+ """Replace what a current memory says. The old text stays as a superseded version. Needs `write`. Not retried."""
287
+ return Memory.model_validate(
288
+ await self._c._request("PATCH", f"/v1/memories/{_q(memory_id)}", body={"content": content}, retry=False)
289
+ )
290
+
291
+ async def page(
292
+ self, *, status: Optional[MemoryStatus] = None, limit: Optional[int] = None, cursor: Optional[str] = None
293
+ ) -> tuple[list[Memory], Optional[str]]:
294
+ out = await self._c._request(
295
+ "GET", "/v1/memories", query={"status": status, "limit": limit, "cursor": cursor}, retry=True
296
+ )
297
+ return [Memory.model_validate(m) for m in out["items"]], out["nextCursor"]
298
+
299
+ async def iter(self, *, status: Optional[MemoryStatus] = None, page_size: int = 100) -> AsyncIterator[Memory]:
300
+ cursor: Optional[str] = None
301
+ while True:
302
+ items, cursor = await self.page(status=status, limit=page_size, cursor=cursor)
303
+ for m in items:
304
+ yield m
305
+ if not cursor:
306
+ return
307
+
308
+
309
+ class _AJobs:
310
+ def __init__(self, c: AsyncKaibo) -> None:
311
+ self._c = c
312
+
313
+ async def get(self, job_id: str) -> JobStatus:
314
+ return JobStatus.model_validate(await self._c._request("GET", f"/v1/jobs/{_q(job_id)}", retry=True))
315
+
316
+ async def wait(self, job_id: str, *, timeout: float = 10.0, interval: float = 0.1) -> JobStatus:
317
+ deadline = time.monotonic() + timeout
318
+ delay = interval
319
+ while True:
320
+ s = await self.get(job_id)
321
+ if s.done:
322
+ return s
323
+ if time.monotonic() + delay > deadline:
324
+ raise TimeoutError(f"job {job_id} did not finish in time")
325
+ await self._c._sleep(delay)
326
+ delay = min(1.0, delay * 2)
327
+
328
+
329
+ class _AWebhooks:
330
+ def __init__(self, c: AsyncKaibo) -> None:
331
+ self._c = c
332
+
333
+ async def create(self, url: str, *, events: Optional[Sequence[WebhookEventType]] = None) -> CreatedWebhook:
334
+ body: dict[str, Any] = {"url": url}
335
+ if events is not None:
336
+ body["events"] = list(events)
337
+ return CreatedWebhook.model_validate(await self._c._request("POST", "/v1/webhooks", body=body, retry=False))
338
+
339
+ async def list(self) -> List[Webhook]:
340
+ return [Webhook.model_validate(w) for w in (await self._c._request("GET", "/v1/webhooks", retry=True))["items"]]
341
+
342
+ async def deliveries(self, webhook_id: str, *, limit: Optional[int] = None) -> List[WebhookDelivery]:
343
+ out = await self._c._request(
344
+ "GET", f"/v1/webhooks/{_q(webhook_id)}/deliveries", query={"limit": limit}, retry=True
345
+ )
346
+ return [WebhookDelivery.model_validate(d) for d in out["items"]]
347
+
348
+ async def test(self, webhook_id: str) -> None:
349
+ await self._c._request("POST", f"/v1/webhooks/{_q(webhook_id)}/test", retry=False)
350
+
351
+ async def delete(self, webhook_id: str) -> None:
352
+ await self._c._request("DELETE", f"/v1/webhooks/{_q(webhook_id)}", retry=False)
353
+
354
+
355
+ class _AImports:
356
+ def __init__(self, c: AsyncKaibo) -> None:
357
+ self._c = c
358
+
359
+ async def create(
360
+ self,
361
+ source: ImportSource,
362
+ content: str,
363
+ *,
364
+ stream: Optional[str] = None,
365
+ name: Optional[str] = None,
366
+ include_invalid: bool = False,
367
+ dry_run: bool = False,
368
+ ) -> ImportResult:
369
+ out = await self._c._request(
370
+ "POST",
371
+ "/v1/imports",
372
+ query={"dryRun": "true"} if dry_run else None,
373
+ body=_import_body(source, content, stream, name, include_invalid),
374
+ retry=True,
375
+ )
376
+ return ImportResult.model_validate(out)
377
+
378
+
379
+ class _AExports:
380
+ def __init__(self, c: AsyncKaibo) -> None:
381
+ self._c = c
382
+
383
+ async def stream(
384
+ self,
385
+ *,
386
+ stream: Optional[str] = None,
387
+ include: Optional[Sequence[str]] = None,
388
+ memory_status: Optional[Sequence[str]] = None,
389
+ ) -> AsyncIterator[ExportRecord]:
390
+ p = self._c._cfg.prepare("POST", "/v1/exports", body=_export_body(stream, include, memory_status))
391
+ res = await self._c._send(p, retry=False, stream=True)
392
+ ended = False
393
+ try:
394
+ async for line in res.aiter_lines():
395
+ if not line.strip():
396
+ continue
397
+ rec = _export_record(line)
398
+ ended = ended or rec.record == "end"
399
+ yield rec
400
+ finally:
401
+ await res.aclose()
402
+ if not ended:
403
+ raise KaiboError("the export was cut off before its end record")
404
+
405
+ async def create(
406
+ self,
407
+ *,
408
+ stream: Optional[str] = None,
409
+ include: Optional[Sequence[str]] = None,
410
+ memory_status: Optional[Sequence[str]] = None,
411
+ ) -> List[ExportRecord]:
412
+ return [r async for r in self.stream(stream=stream, include=include, memory_status=memory_status)]
413
+
414
+
415
+ class _AProjects:
416
+ def __init__(self, c: AsyncKaibo) -> None:
417
+ self._c = c
418
+
419
+ async def list(self) -> List[Project]:
420
+ return [Project.model_validate(p) for p in (await self._c._request("GET", "/v1/projects", retry=True))["items"]]
421
+
422
+ async def create(self, name: str) -> Project:
423
+ return Project.model_validate(await self._c._request("POST", "/v1/projects", body={"name": name}, retry=False))
424
+
425
+ async def delete(self, project_id: str) -> None:
426
+ await self._c._request("DELETE", f"/v1/projects/{_q(project_id)}", retry=False)
427
+
428
+ async def rename(self, project_id: str, name: str) -> Project:
429
+ return Project.model_validate(
430
+ await self._c._request("PATCH", f"/v1/projects/{_q(project_id)}", body={"name": name}, retry=True)
431
+ )
432
+
433
+
434
+ class _AKeys:
435
+ def __init__(self, c: AsyncKaibo) -> None:
436
+ self._c = c
437
+
438
+ async def list(self) -> List[ApiKey]:
439
+ return [ApiKey.model_validate(k) for k in (await self._c._request("GET", "/v1/keys", retry=True))["items"]]
440
+
441
+ async def create(
442
+ self,
443
+ label: str,
444
+ scopes: Sequence[KeyScope],
445
+ *,
446
+ project_id: Optional[str] = None,
447
+ expires_at: Optional[str] = None,
448
+ ) -> CreatedApiKey:
449
+ body = _key_body(label, scopes, project_id, expires_at)
450
+ return CreatedApiKey.model_validate(await self._c._request("POST", "/v1/keys", body=body, retry=False))
451
+
452
+ async def revoke(self, key_id: str) -> ApiKey:
453
+ return ApiKey.model_validate(await self._c._request("DELETE", f"/v1/keys/{_q(key_id)}", retry=True))