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 +18 -0
- idemcore/_types.py +62 -0
- idemcore/_version.py +1 -0
- idemcore/client.py +486 -0
- idemcore/errors.py +89 -0
- idemcore/py.typed +0 -0
- idemcore/verify.py +394 -0
- idemcore-0.1.0.dist-info/METADATA +167 -0
- idemcore-0.1.0.dist-info/RECORD +12 -0
- idemcore-0.1.0.dist-info/WHEEL +5 -0
- idemcore-0.1.0.dist-info/licenses/LICENSE +202 -0
- idemcore-0.1.0.dist-info/top_level.txt +1 -0
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
|