idemcore 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.
idemcore/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Idemcore Python SDK: chat completions with signed receipts, and local verification."""
3
+ from ._version import __version__
4
+ from ._types import ChatResult, Message, ReceiptLog, StreamChunk, Usage
5
+ from .client import AsyncChatStream, AsyncIdemcore, ChatStream, Idemcore, DEFAULT_BASE_URL
6
+ from .errors import (APIConnectionError, AuthenticationError, ConflictError, IdemcoreError, InsufficientQuotaError,
7
+ InternalServerError, InvalidRequestError, NotFoundError, PermissionDeniedError,
8
+ RateLimitError, ServiceUnavailableError)
9
+ from .verify import DEFAULT_TRUSTED_KEYS, VerificationResult, verify_receipt
10
+
11
+ __all__ = [
12
+ "__version__", "Idemcore", "AsyncIdemcore", "ChatStream", "AsyncChatStream", "DEFAULT_BASE_URL",
13
+ "ChatResult", "StreamChunk", "ReceiptLog", "Message", "Usage",
14
+ "IdemcoreError", "APIConnectionError", "InvalidRequestError", "AuthenticationError",
15
+ "PermissionDeniedError", "NotFoundError", "ConflictError", "RateLimitError", "InsufficientQuotaError",
16
+ "ServiceUnavailableError", "InternalServerError",
17
+ "verify_receipt", "VerificationResult", "DEFAULT_TRUSTED_KEYS",
18
+ ]
idemcore/_types.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Result types. Every result keeps the full JSON body in ``raw``."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from typing import Literal, TypedDict
9
+
10
+ Role = Literal["system", "user", "assistant"]
11
+ Privacy = Literal["hash_only", "full"]
12
+
13
+
14
+ class Message(TypedDict):
15
+ role: Role
16
+ content: str
17
+
18
+
19
+ class Usage(TypedDict, total=False):
20
+ prompt_tokens: int
21
+ completion_tokens: int
22
+ total_tokens: int
23
+
24
+
25
+ @dataclass
26
+ class ChatResult:
27
+ """A chat completion with its signed receipt.
28
+
29
+ ``salt`` (hash_only receipts only) is the base64url salt the prompt/output
30
+ commitments were made with. Anyone who holds it and the text can check the
31
+ commitments, so store it like the conversation itself.
32
+ """
33
+ text: str
34
+ receipt_id: str
35
+ receipt: Dict[str, Any]
36
+ id: str
37
+ model: str
38
+ finish_reason: Optional[str]
39
+ usage: Usage
40
+ privacy: str
41
+ salt: Optional[str] = None
42
+ output_token_ids: Optional[List[int]] = None
43
+ messages: List[Message] = field(default_factory=list)
44
+ rate_limit: Dict[str, str] = field(default_factory=dict)
45
+ raw: Dict[str, Any] = field(default_factory=dict)
46
+
47
+
48
+ @dataclass
49
+ class StreamChunk:
50
+ """One ``chat.completion.chunk`` event. ``delta`` is the new text (may be empty)."""
51
+ delta: str
52
+ finish_reason: Optional[str]
53
+ raw: Dict[str, Any]
54
+
55
+
56
+ @dataclass
57
+ class ReceiptLog:
58
+ receipt_id: str
59
+ receipt: Dict[str, Any]
60
+ log_format: str
61
+ log_text: Optional[str]
62
+ raw: Dict[str, Any] = field(default_factory=dict)
idemcore/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
idemcore/client.py ADDED
@@ -0,0 +1,486 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """HTTP client for the Idemcore API (``https://api.idemcore.net``).
3
+
4
+ Standard library only (``urllib``). Responses that carry an Idemcore receipt are
5
+ returned as typed results; everything else as plain ``dict``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import re
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Mapping, Optional, Sequence, Union
18
+
19
+ from ._types import ChatResult, Message, Privacy, ReceiptLog, StreamChunk
20
+ from ._version import __version__
21
+ from .errors import APIConnectionError, IdemcoreError, InvalidRequestError, error_from
22
+ from . import verify as _verify
23
+
24
+ DEFAULT_BASE_URL = "https://api.idemcore.net"
25
+ _RECEIPT_ID = re.compile(r"^rcpt_[0-9a-f]{24}$")
26
+ MessagesLike = Union[str, Sequence[Mapping[str, str]]]
27
+
28
+
29
+ def _messages(m: MessagesLike) -> List[Message]:
30
+ if isinstance(m, str):
31
+ return [{"role": "user", "content": m}]
32
+ out: List[Message] = []
33
+ for x in m:
34
+ out.append({"role": x["role"], "content": x["content"]}) # type: ignore[typeddict-item]
35
+ return out
36
+
37
+
38
+ def _chat_body(messages: List[Message], *, model: Optional[str], max_tokens: Optional[int],
39
+ privacy: Optional[str], temperature: Optional[float], stream: bool) -> Dict[str, Any]:
40
+ if temperature not in (None, 0):
41
+ raise InvalidRequestError("Idemcore uses greedy decoding: temperature is fixed at 0", status=None,
42
+ type="invalid_request_error")
43
+ if not 1 <= len(messages) <= 64:
44
+ raise InvalidRequestError("messages must contain 1 to 64 items", type="invalid_request_error")
45
+ body: Dict[str, Any] = {"messages": messages}
46
+ if model is not None:
47
+ body["model"] = model
48
+ if max_tokens is not None:
49
+ if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or not 1 <= max_tokens <= 2048:
50
+ raise InvalidRequestError("max_tokens must be an integer from 1 to 2048", type="invalid_request_error")
51
+ body["max_tokens"] = max_tokens
52
+ if privacy is not None:
53
+ if privacy not in ("hash_only", "full"):
54
+ raise InvalidRequestError("privacy must be 'hash_only' or 'full'", type="invalid_request_error")
55
+ body["idemcore"] = {"privacy": privacy}
56
+ if stream:
57
+ body["stream"] = True
58
+ body["stream_options"] = {"include_usage": True}
59
+ return body
60
+
61
+
62
+ def _chat_result(data: Dict[str, Any], messages: List[Message], headers: Mapping[str, str]) -> ChatResult:
63
+ choice = (data.get("choices") or [{}])[0]
64
+ idem = data.get("idemcore") or {}
65
+ receipt = data.get("idemcore_receipt") or idem.get("receipt") or {}
66
+ disc = idem.get("disclosure") or {}
67
+ rid = receipt.get("receipt_id") or headers.get("idemcore-receipt-id") or ""
68
+ return ChatResult(
69
+ text=(choice.get("message") or {}).get("content") or "",
70
+ receipt_id=rid,
71
+ receipt=receipt,
72
+ id=data.get("id", ""),
73
+ model=data.get("model", ""),
74
+ finish_reason=choice.get("finish_reason"),
75
+ usage=data.get("usage") or {},
76
+ privacy=receipt.get("privacy", ""),
77
+ salt=data.get("idemcore_salt") or disc.get("salt"),
78
+ output_token_ids=disc.get("output_token_ids"),
79
+ messages=messages,
80
+ rate_limit={k: v for k, v in headers.items() if k.startswith("x-ratelimit-") or k == "retry-after"},
81
+ raw=data,
82
+ )
83
+
84
+
85
+ class ChatStream:
86
+ """Iterate over :class:`StreamChunk` objects. After the stream ends, ``result``
87
+ holds the full :class:`ChatResult` (text, receipt, salt, usage)."""
88
+
89
+ def __init__(self, resp: Any, messages: List[Message], headers: Mapping[str, str]):
90
+ self._resp = resp
91
+ self._messages = messages
92
+ self._headers = dict(headers)
93
+ self.receipt_id: str = self._headers.get("idemcore-receipt-id", "")
94
+ self.result: Optional[ChatResult] = None
95
+ self._text: List[str] = []
96
+ self._final: Dict[str, Any] = {}
97
+ self._usage: Dict[str, Any] = {}
98
+ self._done = False
99
+
100
+ def __enter__(self) -> "ChatStream":
101
+ return self
102
+
103
+ def __exit__(self, *exc: Any) -> None:
104
+ self.close()
105
+
106
+ def close(self) -> None:
107
+ try:
108
+ self._resp.close()
109
+ except Exception:
110
+ pass
111
+
112
+ def __iter__(self) -> Iterator[StreamChunk]:
113
+ try:
114
+ for ev in _sse_events(self._resp):
115
+ if ev == "[DONE]":
116
+ break
117
+ data = json.loads(ev)
118
+ if "error" in data:
119
+ err = data["error"] or {}
120
+ raise error_from(None, err.get("type"), err.get("message", "stream error"), self._headers)
121
+ if data.get("usage"):
122
+ self._usage = data["usage"]
123
+ if "idemcore_receipt" in data:
124
+ self._final = data
125
+ choices = data.get("choices") or []
126
+ if not choices:
127
+ continue
128
+ c = choices[0]
129
+ delta = (c.get("delta") or {}).get("content") or ""
130
+ if delta:
131
+ self._text.append(delta)
132
+ yield StreamChunk(delta=delta, finish_reason=c.get("finish_reason"), raw=data)
133
+ finally:
134
+ self.close()
135
+ self._finish()
136
+
137
+ def _finish(self) -> None:
138
+ if self._done:
139
+ return
140
+ self._done = True
141
+ if not self._final:
142
+ raise APIConnectionError("stream ended before the receipt arrived", headers=self._headers)
143
+ final = dict(self._final)
144
+ fc = (final.get("choices") or [{}])[0]
145
+ final["choices"] = [{"index": 0, "message": {"role": "assistant", "content": "".join(self._text)},
146
+ "finish_reason": fc.get("finish_reason")}]
147
+ final["usage"] = self._usage or final.get("usage") or {}
148
+ self.result = _chat_result(final, self._messages, self._headers)
149
+
150
+ def until_done(self) -> ChatResult:
151
+ """Consume the rest of the stream and return the final result."""
152
+ for _ in self:
153
+ pass
154
+ assert self.result is not None
155
+ return self.result
156
+
157
+
158
+ def _sse_events(resp: Any) -> Iterator[str]:
159
+ buf: List[str] = []
160
+ for raw in resp:
161
+ line = raw.decode("utf-8").rstrip("\r\n")
162
+ if not line:
163
+ if buf:
164
+ yield "\n".join(buf)
165
+ buf = []
166
+ continue
167
+ if line.startswith(":"):
168
+ continue # keep-alive comment
169
+ if line.startswith("data:"):
170
+ buf.append(line[5:].lstrip(" "))
171
+ if buf:
172
+ yield "\n".join(buf)
173
+
174
+
175
+ class Idemcore:
176
+ """Synchronous client.
177
+
178
+ >>> client = Idemcore() # reads IDEMCORE_API_KEY
179
+ >>> r = client.chat("Name the three primary colours.")
180
+ >>> r.text, r.receipt_id
181
+
182
+ Decoding is greedy: temperature is fixed at 0, so the same request on the same
183
+ model gives the same answer, and every answer carries a signed receipt.
184
+ """
185
+
186
+ def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None,
187
+ timeout: float = 120.0, max_retries: int = 2, user_agent: Optional[str] = None):
188
+ self._api_key = api_key if api_key is not None else os.environ.get("IDEMCORE_API_KEY")
189
+ url = (base_url or os.environ.get("IDEMCORE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
190
+ self.base_url = url[:-3] if url.endswith("/v1") else url
191
+ self.timeout = timeout
192
+ self.max_retries = max(0, int(max_retries))
193
+ self.user_agent = user_agent or f"idemcore-python/{__version__}"
194
+
195
+ def __repr__(self) -> str: # never show the key
196
+ return f"Idemcore(base_url={self.base_url!r}, api_key={'set' if self._api_key else 'missing'})"
197
+
198
+ # ---------------------------------------------------------------- transport
199
+ def _request(self, method: str, path: str, *, body: Any = None, query: Optional[Mapping[str, Any]] = None,
200
+ auth: bool = True, stream: bool = False, raw: bool = False):
201
+ url = self.base_url + path
202
+ if query:
203
+ q = {k: v for k, v in query.items() if v is not None}
204
+ if q:
205
+ url += "?" + urllib.parse.urlencode(q)
206
+ headers = {"User-Agent": self.user_agent, "Accept": "text/event-stream" if stream else "application/json"}
207
+ data = None
208
+ if body is not None:
209
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
210
+ headers["Content-Type"] = "application/json"
211
+ if auth:
212
+ if not self._api_key:
213
+ raise error_from(401, "authentication_error",
214
+ "no API key: pass api_key= or set IDEMCORE_API_KEY")
215
+ headers["Authorization"] = "Bearer " + self._api_key
216
+ retries = self.max_retries if method == "GET" else 0 # never repeat a chat call
217
+ attempt = 0
218
+ while True:
219
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
220
+ try:
221
+ resp = urllib.request.urlopen(req, timeout=self.timeout)
222
+ except urllib.error.HTTPError as e:
223
+ hdrs = {k.lower(): v for k, v in e.headers.items()}
224
+ text = e.read().decode("utf-8", "replace")
225
+ e.close()
226
+ try:
227
+ err = (json.loads(text).get("error") or {})
228
+ except (ValueError, AttributeError):
229
+ err = {}
230
+ exc = error_from(e.code, err.get("type"), err.get("message") or f"HTTP {e.code}", hdrs)
231
+ if attempt < retries and e.code in (429, 503) and exc.type != "insufficient_quota":
232
+ attempt += 1
233
+ time.sleep(min(float(hdrs.get("retry-after", "1") or 1), 20.0))
234
+ continue
235
+ raise exc from None
236
+ except (urllib.error.URLError, OSError) as e:
237
+ if attempt < retries:
238
+ attempt += 1
239
+ time.sleep(0.5 * attempt)
240
+ continue
241
+ raise APIConnectionError(f"could not reach {self.base_url}: {e}") from None
242
+ hdrs = {k.lower(): v for k, v in resp.headers.items()}
243
+ if stream:
244
+ return resp, hdrs
245
+ with resp:
246
+ payload = resp.read()
247
+ if raw:
248
+ return payload.decode("utf-8"), hdrs
249
+ try:
250
+ return json.loads(payload), hdrs
251
+ except ValueError:
252
+ raise IdemcoreError("API returned a non-JSON response", status=resp.status, headers=hdrs) from None
253
+
254
+ # ---------------------------------------------------------------- endpoints
255
+ def health(self) -> Dict[str, Any]:
256
+ """``GET /health`` (no API key needed)."""
257
+ return self._request("GET", "/health", auth=False)[0]
258
+
259
+ def models(self) -> List[Dict[str, Any]]:
260
+ """``GET /v1/models``: the models this key can use."""
261
+ return self._request("GET", "/v1/models")[0].get("data", [])
262
+
263
+ def chat(self, messages: MessagesLike, *, model: Optional[str] = None, max_tokens: Optional[int] = None,
264
+ privacy: Optional[Privacy] = None, temperature: Optional[float] = None) -> ChatResult:
265
+ """``POST /v1/chat/completions``. ``messages`` may be a plain string (one user
266
+ message). ``privacy="hash_only"`` (the default) stores only salted hashes;
267
+ ``"full"`` also stores the conversation for server-side replay."""
268
+ msgs = _messages(messages)
269
+ body = _chat_body(msgs, model=model, max_tokens=max_tokens, privacy=privacy,
270
+ temperature=temperature, stream=False)
271
+ data, hdrs = self._request("POST", "/v1/chat/completions", body=body)
272
+ return _chat_result(data, msgs, hdrs)
273
+
274
+ def chat_stream(self, messages: MessagesLike, *, model: Optional[str] = None, max_tokens: Optional[int] = None,
275
+ privacy: Optional[Privacy] = None, temperature: Optional[float] = None) -> ChatStream:
276
+ """Streaming chat (server-sent events). The receipt is signed once the answer
277
+ is complete and arrives with the last chunk; ``stream.result`` has it."""
278
+ msgs = _messages(messages)
279
+ body = _chat_body(msgs, model=model, max_tokens=max_tokens, privacy=privacy,
280
+ temperature=temperature, stream=True)
281
+ resp, hdrs = self._request("POST", "/v1/chat/completions", body=body, stream=True)
282
+ return ChatStream(resp, msgs, hdrs)
283
+
284
+ def get_receipt(self, receipt_id: str) -> Dict[str, Any]:
285
+ """``GET /v1/receipts/{id}``."""
286
+ return self._request("GET", f"/v1/receipts/{_rid(receipt_id)}")[0]["receipt"]
287
+
288
+ def get_receipt_log(self, receipt_id: str) -> ReceiptLog:
289
+ """``GET /v1/receipts/{id}/log``: the receipt and its public token log."""
290
+ d = self._request("GET", f"/v1/receipts/{_rid(receipt_id)}/log")[0]
291
+ return ReceiptLog(d.get("receipt_id", receipt_id), d.get("receipt") or {}, d.get("log_format", ""),
292
+ d.get("log_text"), d)
293
+
294
+ def keys(self) -> Dict[str, Any]:
295
+ """``GET /v1/keys``: the receipt-signing public keys (JWKS)."""
296
+ return self._request("GET", "/v1/keys")[0]
297
+
298
+ def verify_remote(self, receipt: Mapping[str, Any], *, log_text: Optional[str] = None,
299
+ messages: Optional[MessagesLike] = None, salt: Optional[str] = None,
300
+ output_token_ids: Optional[Sequence[int]] = None,
301
+ output_text: Optional[str] = None) -> Dict[str, Any]:
302
+ """``POST /v1/receipts/verify``: server-side L1, L2 and disclosure checks."""
303
+ body: Dict[str, Any] = {"receipt": dict(receipt)}
304
+ if log_text is not None:
305
+ body["log_text"] = log_text
306
+ disc: Dict[str, Any] = {}
307
+ if messages is not None:
308
+ disc["messages"] = _messages(messages)
309
+ if salt is not None:
310
+ disc["salt"] = salt
311
+ if output_token_ids is not None:
312
+ disc["output_token_ids"] = list(output_token_ids)
313
+ if output_text is not None:
314
+ disc["output_text"] = output_text
315
+ if disc:
316
+ body["disclosure"] = disc
317
+ return self._request("POST", "/v1/receipts/verify", body=body)[0]
318
+
319
+ def replay(self, receipt_id: str, *, messages: Optional[MessagesLike] = None, salt: Optional[str] = None,
320
+ output_token_ids: Optional[Sequence[int]] = None, build: Optional[str] = None) -> Dict[str, Any]:
321
+ """``POST /v1/receipts/replay`` (L3): re-run the answer on an independent
322
+ build and compare chain digests. ``hash_only`` receipts need ``messages``
323
+ and ``salt``; ``full`` receipts replay from the stored conversation."""
324
+ body: Dict[str, Any] = {"receipt_id": _rid(receipt_id)}
325
+ if messages is not None:
326
+ body["messages"] = _messages(messages)
327
+ if salt is not None:
328
+ body["salt"] = salt
329
+ if output_token_ids is not None:
330
+ body["output_token_ids"] = list(output_token_ids)
331
+ if build is not None:
332
+ if build not in ("main", "alt"):
333
+ raise InvalidRequestError("build must be 'main' or 'alt'", type="invalid_request_error")
334
+ body["build"] = build
335
+ return self._request("POST", "/v1/receipts/replay", body=body)[0]
336
+
337
+ def replay_result(self, result: ChatResult, *, build: Optional[str] = None) -> Dict[str, Any]:
338
+ """Replay a :class:`ChatResult` with its own disclosure (messages, salt, ids)."""
339
+ if result.privacy == "full":
340
+ return self.replay(result.receipt_id, build=build)
341
+ return self.replay(result.receipt_id, messages=result.messages, salt=result.salt,
342
+ output_token_ids=result.output_token_ids, build=build)
343
+
344
+ def audit_export(self, since: Optional[str] = None, until: Optional[str] = None, *,
345
+ include_logs: bool = False, format: str = "ndjson") -> Union[List[Dict[str, Any]], Dict[str, Any]]:
346
+ """``POST /v1/audit/export``. ``since``/``until`` are ``YYYY-MM-DD`` (UTC days,
347
+ ``until`` exclusive, at most 31 days). ``format="ndjson"`` returns the list of
348
+ export lines (header, receipts, events, roots, logs); ``"json"`` one object."""
349
+ body: Dict[str, Any] = {}
350
+ if since:
351
+ body["since"] = since
352
+ if until:
353
+ body["until"] = until
354
+ if include_logs:
355
+ body["include_logs"] = True
356
+ if format == "json":
357
+ body["format"] = "json"
358
+ return self._request("POST", "/v1/audit/export", body=body)[0]
359
+ if format != "ndjson":
360
+ raise InvalidRequestError("format must be 'ndjson' or 'json'", type="invalid_request_error")
361
+ text, _ = self._request("POST", "/v1/audit/export", body=body, raw=True)
362
+ return [json.loads(line) for line in text.splitlines() if line.strip()]
363
+
364
+ # ---------------------------------------------------------------- local verification
365
+ def verify_local(self, result: Union[ChatResult, Mapping[str, Any]], *, fetch_log: bool = True,
366
+ keys: _verify.KeysLike = None, check_signature: bool = True,
367
+ output_text: Optional[str] = None) -> _verify.VerificationResult:
368
+ """Verify a receipt on this machine. For a :class:`ChatResult`, the disclosure
369
+ (messages, salt, output ids, text) is checked too. With ``fetch_log=True`` the
370
+ public token log is downloaded and L2 is run. ``keys`` defaults to the bundled
371
+ Idemcore signing keys."""
372
+ if isinstance(result, ChatResult):
373
+ receipt = result.receipt
374
+ kw: Dict[str, Any] = {"messages": result.messages, "salt": result.salt,
375
+ "output_token_ids": result.output_token_ids,
376
+ "output_text": output_text if output_text is not None else (
377
+ result.text if "output_text_hash" in receipt else None)}
378
+ else:
379
+ receipt = dict(result)
380
+ kw = {"output_text": output_text}
381
+ log_text = None
382
+ if fetch_log:
383
+ log_text = self.get_receipt_log(receipt["receipt_id"]).log_text
384
+ return _verify.verify_receipt(receipt, keys=keys, log_text=log_text,
385
+ check_signature=check_signature, **kw)
386
+
387
+
388
+ def _rid(receipt_id: str) -> str:
389
+ if not isinstance(receipt_id, str) or not _RECEIPT_ID.match(receipt_id):
390
+ raise InvalidRequestError("receipt_id must look like rcpt_ followed by 24 hex digits",
391
+ type="invalid_request_error")
392
+ return receipt_id
393
+
394
+
395
+ class AsyncIdemcore:
396
+ """Asyncio client with the same methods as :class:`Idemcore`. Each call runs the
397
+ blocking request in a worker thread (``asyncio.to_thread``); no extra dependency."""
398
+
399
+ def __init__(self, api_key: Optional[str] = None, **kw: Any):
400
+ self._sync = Idemcore(api_key, **kw)
401
+
402
+ def __repr__(self) -> str:
403
+ return "Async" + repr(self._sync)
404
+
405
+ @property
406
+ def base_url(self) -> str:
407
+ return self._sync.base_url
408
+
409
+ async def health(self) -> Dict[str, Any]:
410
+ return await asyncio.to_thread(self._sync.health)
411
+
412
+ async def models(self) -> List[Dict[str, Any]]:
413
+ return await asyncio.to_thread(self._sync.models)
414
+
415
+ async def chat(self, messages: MessagesLike, **kw: Any) -> ChatResult:
416
+ return await asyncio.to_thread(self._sync.chat, messages, **kw)
417
+
418
+ async def chat_stream(self, messages: MessagesLike, **kw: Any) -> "AsyncChatStream":
419
+ s = await asyncio.to_thread(self._sync.chat_stream, messages, **kw)
420
+ return AsyncChatStream(s)
421
+
422
+ async def get_receipt(self, receipt_id: str) -> Dict[str, Any]:
423
+ return await asyncio.to_thread(self._sync.get_receipt, receipt_id)
424
+
425
+ async def get_receipt_log(self, receipt_id: str) -> ReceiptLog:
426
+ return await asyncio.to_thread(self._sync.get_receipt_log, receipt_id)
427
+
428
+ async def keys(self) -> Dict[str, Any]:
429
+ return await asyncio.to_thread(self._sync.keys)
430
+
431
+ async def verify_remote(self, receipt: Mapping[str, Any], **kw: Any) -> Dict[str, Any]:
432
+ return await asyncio.to_thread(self._sync.verify_remote, receipt, **kw)
433
+
434
+ async def replay(self, receipt_id: str, **kw: Any) -> Dict[str, Any]:
435
+ return await asyncio.to_thread(self._sync.replay, receipt_id, **kw)
436
+
437
+ async def replay_result(self, result: ChatResult, **kw: Any) -> Dict[str, Any]:
438
+ return await asyncio.to_thread(self._sync.replay_result, result, **kw)
439
+
440
+ async def audit_export(self, since: Optional[str] = None, until: Optional[str] = None, **kw: Any):
441
+ return await asyncio.to_thread(self._sync.audit_export, since, until, **kw)
442
+
443
+ async def verify_local(self, result: Union[ChatResult, Mapping[str, Any]], **kw: Any) -> _verify.VerificationResult:
444
+ return await asyncio.to_thread(self._sync.verify_local, result, **kw)
445
+
446
+
447
+ class AsyncChatStream:
448
+ """Async iterator over :class:`StreamChunk`; ``result`` is set at the end."""
449
+
450
+ _END = object()
451
+
452
+ def __init__(self, s: ChatStream):
453
+ self._s = s
454
+ self._it = iter(s)
455
+
456
+ @property
457
+ def result(self) -> Optional[ChatResult]:
458
+ return self._s.result
459
+
460
+ @property
461
+ def receipt_id(self) -> str:
462
+ return self._s.receipt_id
463
+
464
+ def __aiter__(self) -> AsyncIterator[StreamChunk]:
465
+ return self
466
+
467
+ async def __anext__(self) -> StreamChunk:
468
+ v = await asyncio.to_thread(next, self._it, self._END)
469
+ if v is self._END:
470
+ raise StopAsyncIteration
471
+ return v # type: ignore[return-value]
472
+
473
+ async def until_done(self) -> ChatResult:
474
+ async for _ in self:
475
+ pass
476
+ assert self._s.result is not None
477
+ return self._s.result
478
+
479
+ async def aclose(self) -> None:
480
+ self._s.close()
481
+
482
+ async def __aenter__(self) -> "AsyncChatStream":
483
+ return self
484
+
485
+ async def __aexit__(self, *exc: Any) -> None:
486
+ self._s.close()
idemcore/errors.py ADDED
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Errors raised by the client. The API returns ``{"error": {"type", "message"}}``."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Mapping, Optional
6
+
7
+
8
+ class IdemcoreError(Exception):
9
+ """Base class. ``status`` is the HTTP status (``None`` for network errors)."""
10
+
11
+ def __init__(self, message: str, *, type: str = "error", status: Optional[int] = None,
12
+ headers: Optional[Mapping[str, str]] = None):
13
+ super().__init__(message)
14
+ self.message = message
15
+ self.type = type
16
+ self.status = status
17
+ self.headers = dict(headers or {})
18
+
19
+ def __repr__(self) -> str:
20
+ return f"{self.__class__.__name__}(status={self.status!r}, type={self.type!r}, message={self.message!r})"
21
+
22
+
23
+ class APIConnectionError(IdemcoreError):
24
+ """The request did not reach the API or the connection broke."""
25
+
26
+
27
+ class InvalidRequestError(IdemcoreError):
28
+ """400 / 415: the request was rejected (bad parameters)."""
29
+
30
+
31
+ class AuthenticationError(IdemcoreError):
32
+ """401: missing or invalid API key."""
33
+
34
+
35
+ class PermissionDeniedError(IdemcoreError):
36
+ """403: the API key lacks the required scope."""
37
+
38
+
39
+ class NotFoundError(IdemcoreError):
40
+ """404: unknown receipt or endpoint."""
41
+
42
+
43
+ class RateLimitError(IdemcoreError):
44
+ """429 ``rate_limit_error``. ``retry_after`` is in seconds when the API sends it."""
45
+
46
+ @property
47
+ def retry_after(self) -> Optional[float]:
48
+ v = self.headers.get("retry-after")
49
+ try:
50
+ return float(v) if v is not None else None
51
+ except ValueError:
52
+ return None
53
+
54
+
55
+ class InsufficientQuotaError(IdemcoreError):
56
+ """429 ``insufficient_quota``: the organisation's token quota is used up."""
57
+
58
+
59
+ class ServiceUnavailableError(IdemcoreError):
60
+ """503: the inference backend is temporarily unavailable."""
61
+
62
+
63
+ class ConflictError(IdemcoreError):
64
+ """409: e.g. a replay whose stored payload is no longer available."""
65
+
66
+
67
+ class InternalServerError(IdemcoreError):
68
+ """500 or another 5xx."""
69
+
70
+
71
+ _BY_TYPE = {
72
+ "invalid_request_error": InvalidRequestError,
73
+ "authentication_error": AuthenticationError,
74
+ "permission_error": PermissionDeniedError,
75
+ "not_found_error": NotFoundError,
76
+ "rate_limit_error": RateLimitError,
77
+ "insufficient_quota": InsufficientQuotaError,
78
+ "service_unavailable": ServiceUnavailableError,
79
+ "server_error": InternalServerError,
80
+ }
81
+ _BY_STATUS = {400: InvalidRequestError, 401: AuthenticationError, 403: PermissionDeniedError,
82
+ 404: NotFoundError, 409: ConflictError, 413: InvalidRequestError, 415: InvalidRequestError, 429: RateLimitError, 503: ServiceUnavailableError}
83
+
84
+
85
+ def error_from(status: Optional[int], type: Optional[str], message: str,
86
+ headers: Optional[Mapping[str, str]] = None) -> IdemcoreError:
87
+ cls = _BY_TYPE.get(type or "") or _BY_STATUS.get(status or 0) or (
88
+ InternalServerError if status and status >= 500 else IdemcoreError)
89
+ return cls(message, type=type or "error", status=status, headers=headers)
idemcore/py.typed ADDED
File without changes