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/verify.py ADDED
@@ -0,0 +1,394 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Local (offline) verification of Idemcore receipts, format r1.
3
+
4
+ Levels:
5
+
6
+ * ``L1``: receipt format and Ed25519 signature against a trusted key set.
7
+ Uses the ``cryptography`` package for Ed25519.
8
+ * ``L2``: the receipt's ``chain_digest`` and ``chain_fnv`` recomputed from the
9
+ answer's public token log (``GET /v1/receipts/{id}/log``). Standard library only;
10
+ the FNV-1a-64 chain comes from the ``idemcore-verify`` package.
11
+ * ``disclosure``: the prompt, output token ids and displayed text checked against the
12
+ salted commitments in the receipt.
13
+
14
+ L3 (re-running the answer on an independent build) is a server operation:
15
+ see :meth:`idemcore.Idemcore.replay`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import re
23
+ import struct
24
+ import unicodedata
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Union
27
+
28
+ from idemcore_verify.core import FNV_OFFSET, fnv1a64
29
+
30
+ __all__ = [
31
+ "DEFAULT_TRUSTED_KEYS", "TrustedKey", "LevelResult", "VerificationResult",
32
+ "canonical_json", "check_format", "verify_l1", "verify_l2", "verify_disclosure",
33
+ "verify_receipt", "parse_public_log", "log_digests", "load_keys",
34
+ ]
35
+
36
+ #: Idemcore receipt-signing public keys (public halves only), bundled so receipts
37
+ #: verify offline. The live set is published at ``GET /v1/keys``.
38
+ DEFAULT_TRUSTED_KEYS: List[Dict[str, str]] = [
39
+ {"kty": "OKP", "crv": "Ed25519", "x": "AdQX-eipjpmqi4GDvqYHIep2Vb7l5Vas4xncNN453OY",
40
+ "kid": "ed25519:2fab59995ac55e4f"},
41
+ ]
42
+
43
+ RECEIPT_VERSION = "r1"
44
+ SIG_DOMAIN = b"idemcore-receipt/r1\n"
45
+ CHAIN_DOMAIN = b"idemcore/chain/r1\x00"
46
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
47
+ _HEX16 = re.compile(r"^[0-9a-f]{16}$")
48
+ _HEXF = re.compile(r"^[0-9a-fA-F]{1,16}$")
49
+ _ISSUED = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,6})?Z$")
50
+ _REQUIRED = {
51
+ "receipt_version": str, "receipt_id": str, "issued_at": str, "tenant_id": str,
52
+ "model_id": str, "weights_hash": str, "kernel_version": str, "engine_build": str,
53
+ "privacy": str, "prompt_hash": str, "params_hash": str, "seed": int, "decoding": str,
54
+ "prompt_tokens": int, "output_tokens": int, "output_token_hash": str,
55
+ "chain_digest": str, "chain_fnv": str, "log_ref": str, "key_id": str, "signature": str,
56
+ }
57
+ _OPTIONAL = {"finish_reason": str, "request_id": str, "output_text_hash": str}
58
+ _HASH_FIELDS = ("weights_hash", "prompt_hash", "params_hash", "output_token_hash", "chain_digest")
59
+ _SAFE_INT = 2 ** 53 - 1
60
+
61
+
62
+ # ------------------------------------------------------------------ encoding helpers
63
+ def b64u(b: bytes) -> str:
64
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
65
+
66
+
67
+ def b64u_decode(s: str) -> bytes:
68
+ if not isinstance(s, str) or not re.match(r"^[A-Za-z0-9_-]*$", s):
69
+ raise ValueError("not base64url")
70
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
71
+
72
+
73
+ def _check_canonical(v: Any, path: str = "$") -> None:
74
+ if isinstance(v, bool) or v is None or isinstance(v, str):
75
+ return
76
+ if isinstance(v, int):
77
+ if not -_SAFE_INT <= v <= _SAFE_INT:
78
+ raise ValueError(f"{path}: integer outside the safe range")
79
+ return
80
+ if isinstance(v, float):
81
+ raise ValueError(f"{path}: only integers are allowed")
82
+ if isinstance(v, (list, tuple)):
83
+ for i, x in enumerate(v):
84
+ _check_canonical(x, f"{path}[{i}]")
85
+ return
86
+ if isinstance(v, dict):
87
+ for k, x in v.items():
88
+ if not isinstance(k, str):
89
+ raise ValueError(f"{path}: object keys must be strings")
90
+ _check_canonical(x, f"{path}.{k}")
91
+ return
92
+ raise ValueError(f"{path}: unsupported type {type(v).__name__}")
93
+
94
+
95
+ def canonical_json(obj: Any) -> bytes:
96
+ """Canonical JSON (receipts r1): keys sorted by code point, no whitespace,
97
+ UTF-8 with non-ASCII literal, integers only."""
98
+ _check_canonical(obj)
99
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
100
+ allow_nan=False).encode("utf-8")
101
+
102
+
103
+ def tagged_hash(tag: str, *parts: bytes) -> str:
104
+ h = hashlib.sha256(f"idemcore/{tag}/r1".encode() + b"\x00")
105
+ for p in parts:
106
+ h.update(struct.pack("<Q", len(p)))
107
+ h.update(p)
108
+ return h.hexdigest()
109
+
110
+
111
+ _WS = re.compile("[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+")
112
+
113
+
114
+ def normalize_text(text: str) -> str:
115
+ """Displayed-text normalisation: NFC, every whitespace run becomes one space, trimmed."""
116
+ s = _WS.sub(" ", unicodedata.normalize("NFC", text))
117
+ if s.startswith(" "):
118
+ s = s[1:]
119
+ if s.endswith(" "):
120
+ s = s[:-1]
121
+ return s
122
+
123
+
124
+ def prompt_hash(messages: Sequence[Mapping[str, Any]], salt: bytes) -> str:
125
+ return tagged_hash("prompt", salt, canonical_json(list(messages)))
126
+
127
+
128
+ def token_hash(ids: Sequence[int], salt: bytes) -> str:
129
+ for i, t in enumerate(ids):
130
+ if not isinstance(t, int) or isinstance(t, bool) or not 0 <= t <= 0xFFFFFFFF:
131
+ raise ValueError(f"token id out of range at {i}")
132
+ return tagged_hash("tokens", salt, struct.pack(f"<{len(ids)}I", *ids))
133
+
134
+
135
+ def text_hash(text: str, salt: bytes) -> str:
136
+ return tagged_hash("text", salt, normalize_text(text).encode("utf-8"))
137
+
138
+
139
+ # ------------------------------------------------------------------ public token log
140
+ @dataclass(frozen=True)
141
+ class LogLine:
142
+ pi: int
143
+ j: int
144
+ h: int
145
+ hc: int
146
+
147
+
148
+ def parse_public_log(text: str):
149
+ """Parse a public token log (``idemcore-log/r1-public``: ``pi j h hc`` per line
150
+ and an optional ``ALL <fnv>`` line). Returns ``(lines, all_value_or_None)``."""
151
+ lines: List[LogLine] = []
152
+ all_v: Optional[str] = None
153
+ for n, raw in enumerate(text.splitlines(), 1):
154
+ s = raw.strip()
155
+ if not s or s.startswith("#"):
156
+ continue
157
+ f = s.split()
158
+ if f[0] == "ALL":
159
+ if len(f) != 2 or not _HEXF.match(f[1]):
160
+ raise ValueError(f"log line {n}: malformed ALL line")
161
+ all_v = f[1].lower()
162
+ continue
163
+ if not (f[0].isdigit() and f[0].isascii()):
164
+ continue # reserved keyword lines
165
+ if len(f) != 4:
166
+ raise ValueError(f"log line {n}: not a public log line (expected 4 fields: pi j h hc)")
167
+ if not (f[1].isdigit() and f[1].isascii() and _HEXF.match(f[2]) and _HEXF.match(f[3])):
168
+ raise ValueError(f"log line {n}: malformed field")
169
+ ln = LogLine(int(f[0]), int(f[1]), int(f[2], 16), int(f[3], 16))
170
+ if ln.pi >= 2 ** 64 or ln.j >= 2 ** 64:
171
+ raise ValueError(f"log line {n}: index out of range")
172
+ lines.append(ln)
173
+ return lines, all_v
174
+
175
+
176
+ def chain_fnv(lines: Iterable[LogLine]) -> str:
177
+ a = FNV_OFFSET
178
+ for ln in lines:
179
+ a = fnv1a64(struct.pack("<Q", ln.h), a)
180
+ a = fnv1a64(struct.pack("<Q", ln.hc), a)
181
+ return f"{a:016x}"
182
+
183
+
184
+ def chain_digest(lines: Iterable[LogLine]) -> str:
185
+ c = hashlib.sha256(CHAIN_DOMAIN).digest()
186
+ for ln in lines:
187
+ c = hashlib.sha256(c + struct.pack("<QQQQ", ln.pi, ln.j, ln.h, ln.hc)).digest()
188
+ return c.hex()
189
+
190
+
191
+ def log_digests(text: str) -> Dict[str, Any]:
192
+ lines, all_v = parse_public_log(text)
193
+ return {"positions": len(lines), "chain_digest": chain_digest(lines),
194
+ "chain_fnv": chain_fnv(lines), "all_line": all_v}
195
+
196
+
197
+ # ------------------------------------------------------------------ keys
198
+ @dataclass(frozen=True)
199
+ class TrustedKey:
200
+ key_id: str
201
+ raw: bytes
202
+ source: str = "custom"
203
+
204
+ @classmethod
205
+ def from_jwk(cls, jwk: Mapping[str, Any], source: str = "custom") -> "TrustedKey":
206
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(jwk.get("x"), str):
207
+ raise ValueError("not an Ed25519 public JWK")
208
+ raw = b64u_decode(jwk["x"])
209
+ if len(raw) != 32:
210
+ raise ValueError("Ed25519 public key must be 32 bytes")
211
+ kid = "ed25519:" + hashlib.sha256(raw).hexdigest()[:16]
212
+ if jwk.get("kid") and jwk["kid"] != kid:
213
+ raise ValueError(f"kid {jwk['kid']} does not match the key bytes ({kid})")
214
+ return cls(kid, raw, source)
215
+
216
+ def verify(self, sig: bytes, msg: bytes) -> bool:
217
+ try:
218
+ from cryptography.exceptions import InvalidSignature
219
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
220
+ except ImportError as e: # pragma: no cover - exercised only without the extra
221
+ raise ImportError("signature checks need the 'cryptography' package: pip install cryptography") from e
222
+ try:
223
+ Ed25519PublicKey.from_public_bytes(self.raw).verify(sig, msg)
224
+ return True
225
+ except (InvalidSignature, ValueError):
226
+ return False
227
+
228
+
229
+ KeysLike = Union[None, Mapping[str, Any], Sequence[Any]]
230
+
231
+
232
+ def load_keys(keys: KeysLike = None, source: str = "custom") -> List[TrustedKey]:
233
+ """Accept ``None`` (bundled keys), a JWK, a list of JWKs/TrustedKeys, or a JWKS
234
+ (``{"keys": [...]}``, as returned by ``GET /v1/keys``)."""
235
+ if keys is None:
236
+ return [TrustedKey.from_jwk(k, "bundled") for k in DEFAULT_TRUSTED_KEYS]
237
+ if isinstance(keys, Mapping):
238
+ keys = keys["keys"] if isinstance(keys.get("keys"), list) else [keys]
239
+ return [k if isinstance(k, TrustedKey) else TrustedKey.from_jwk(k, source) for k in keys]
240
+
241
+
242
+ # ------------------------------------------------------------------ results
243
+ @dataclass
244
+ class LevelResult:
245
+ level: str
246
+ ok: bool
247
+ errors: List[str] = field(default_factory=list)
248
+ details: Dict[str, Any] = field(default_factory=dict)
249
+
250
+
251
+ @dataclass
252
+ class VerificationResult:
253
+ ok: bool
254
+ levels: Dict[str, LevelResult]
255
+
256
+ def __bool__(self) -> bool:
257
+ return self.ok
258
+
259
+
260
+ def _res(level: str, errors: List[str], **details: Any) -> LevelResult:
261
+ return LevelResult(level, not errors, errors, details)
262
+
263
+
264
+ # ------------------------------------------------------------------ checks
265
+ def check_format(r: Any) -> List[str]:
266
+ if not isinstance(r, dict):
267
+ return ["receipt is not an object"]
268
+ errs: List[str] = []
269
+ for k, t in _REQUIRED.items():
270
+ if k not in r:
271
+ errs.append(f"missing field {k}")
272
+ elif not isinstance(r[k], t) or isinstance(r[k], bool):
273
+ errs.append(f"field {k} must be {t.__name__}")
274
+ for k in r:
275
+ if k not in _REQUIRED and k not in _OPTIONAL:
276
+ errs.append(f"unknown field {k}")
277
+ for k, t in _OPTIONAL.items():
278
+ if k in r and not isinstance(r[k], t):
279
+ errs.append(f"field {k} must be {t.__name__}")
280
+ if errs:
281
+ return errs
282
+ if r["receipt_version"] != RECEIPT_VERSION:
283
+ errs.append(f"unsupported receipt_version {r['receipt_version']}")
284
+ for k in _HASH_FIELDS:
285
+ if not _HEX64.match(r[k]):
286
+ errs.append(f"{k} must be 64 lowercase hex")
287
+ if "output_text_hash" in r and not _HEX64.match(r["output_text_hash"]):
288
+ errs.append("output_text_hash must be 64 lowercase hex")
289
+ if not _HEX16.match(r["chain_fnv"]):
290
+ errs.append("chain_fnv must be 16 lowercase hex")
291
+ if r["privacy"] not in ("hash_only", "full"):
292
+ errs.append("privacy must be hash_only or full")
293
+ if r["prompt_tokens"] < 1 or r["output_tokens"] < 0:
294
+ errs.append("token counts out of range")
295
+ if not _ISSUED.match(r["issued_at"]):
296
+ errs.append("issued_at must be RFC 3339 UTC (Z)")
297
+ try:
298
+ canonical_json(r)
299
+ except ValueError as e:
300
+ errs.append(str(e))
301
+ return errs
302
+
303
+
304
+ def signing_payload(receipt: Mapping[str, Any]) -> bytes:
305
+ return SIG_DOMAIN + canonical_json({k: v for k, v in receipt.items() if k != "signature"})
306
+
307
+
308
+ def verify_l1(receipt: Any, keys: KeysLike = None) -> LevelResult:
309
+ errs = check_format(receipt)
310
+ if errs:
311
+ return _res("L1", errs)
312
+ trusted = {k.key_id: k for k in load_keys(keys)}
313
+ pk = trusted.get(receipt["key_id"])
314
+ if pk is None:
315
+ return _res("L1", [f"unknown key_id {receipt['key_id']} (not in the trusted key set)"])
316
+ try:
317
+ sig = b64u_decode(receipt["signature"])
318
+ except ValueError:
319
+ return _res("L1", ["signature is not base64url"])
320
+ if len(sig) != 64 or not pk.verify(sig, signing_payload(receipt)):
321
+ return _res("L1", ["signature does not verify"])
322
+ return _res("L1", [], key_id=pk.key_id, key_source=pk.source)
323
+
324
+
325
+ def verify_l2(receipt: Mapping[str, Any], log_text: str) -> LevelResult:
326
+ try:
327
+ d = log_digests(log_text)
328
+ except ValueError as e:
329
+ return _res("L2", [f"log parse error: {e}"])
330
+ errs: List[str] = []
331
+ if d["chain_digest"] != receipt.get("chain_digest"):
332
+ errs.append("chain_digest mismatch")
333
+ if d["chain_fnv"] != receipt.get("chain_fnv"):
334
+ errs.append("chain_fnv mismatch")
335
+ if d["all_line"] is not None and d["all_line"] != d["chain_fnv"]:
336
+ errs.append("log ALL line inconsistent with its own lines")
337
+ want = int(receipt.get("prompt_tokens", 0)) + int(receipt.get("output_tokens", 0))
338
+ if d["positions"] != want:
339
+ errs.append(f"log covers {d['positions']} positions, receipt says {want}")
340
+ return _res("L2", errs, positions=d["positions"])
341
+
342
+
343
+ def _salt_bytes(salt: Union[None, str, bytes]) -> Optional[bytes]:
344
+ if salt is None or isinstance(salt, bytes):
345
+ return salt
346
+ return b64u_decode(salt)
347
+
348
+
349
+ def verify_disclosure(receipt: Mapping[str, Any], *, messages: Optional[Sequence[Mapping[str, Any]]] = None,
350
+ salt: Union[None, str, bytes] = None, output_token_ids: Optional[Sequence[int]] = None,
351
+ output_text: Optional[str] = None) -> LevelResult:
352
+ """Check disclosed values against the receipt. ``salt`` is the base64url string
353
+ returned with the answer (``idemcore_salt``) or raw bytes; ignored for ``full`` receipts."""
354
+ s = b"" if receipt.get("privacy") == "full" else _salt_bytes(salt)
355
+ if s is None:
356
+ return _res("disclosure", ["salt required for hash_only receipts"])
357
+ errs: List[str] = []
358
+ checked: List[str] = []
359
+ if messages is not None:
360
+ checked.append("prompt_hash")
361
+ if prompt_hash(messages, s) != receipt.get("prompt_hash"):
362
+ errs.append("prompt_hash mismatch")
363
+ if output_token_ids is not None:
364
+ checked.append("output_token_hash")
365
+ if token_hash(list(output_token_ids), s) != receipt.get("output_token_hash"):
366
+ errs.append("output_token_hash mismatch")
367
+ if output_text is not None:
368
+ if not isinstance(receipt.get("output_text_hash"), str):
369
+ errs.append("receipt has no output_text_hash; displayed text cannot be bound")
370
+ else:
371
+ checked.append("output_text_hash")
372
+ if text_hash(output_text, s) != receipt["output_text_hash"]:
373
+ errs.append("output_text_hash mismatch")
374
+ return _res("disclosure", errs, checked=checked)
375
+
376
+
377
+ def verify_receipt(receipt: Mapping[str, Any], *, keys: KeysLike = None, log_text: Optional[str] = None,
378
+ messages: Optional[Sequence[Mapping[str, Any]]] = None, salt: Union[None, str, bytes] = None,
379
+ output_token_ids: Optional[Sequence[int]] = None, output_text: Optional[str] = None,
380
+ check_signature: bool = True) -> VerificationResult:
381
+ """Run every level the inputs allow. L1 always (unless ``check_signature=False``);
382
+ L2 when ``log_text`` is given; disclosure when any disclosed value is given."""
383
+ levels: Dict[str, LevelResult] = {}
384
+ if check_signature:
385
+ levels["L1"] = verify_l1(dict(receipt), keys)
386
+ else:
387
+ errs = check_format(dict(receipt))
388
+ levels["format"] = _res("format", errs)
389
+ if log_text is not None:
390
+ levels["L2"] = verify_l2(receipt, log_text)
391
+ if any(v is not None for v in (messages, output_token_ids, output_text)):
392
+ levels["disclosure"] = verify_disclosure(receipt, messages=messages, salt=salt,
393
+ output_token_ids=output_token_ids, output_text=output_text)
394
+ return VerificationResult(all(lv.ok for lv in levels.values()), levels)
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: idemcore
3
+ Version: 0.1.0
4
+ Summary: Python client for the Idemcore API: chat completions with signed receipts, and local receipt verification.
5
+ Author-email: Don Wick <don.wick@idemcore.net>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://idemcore.net
8
+ Project-URL: Documentation, https://idemcore.net/docs
9
+ Project-URL: Verifier, https://github.com/idemcore/idemcore-verifier
10
+ Keywords: llm,inference,receipts,verification,reproducibility,openai-compatible
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: idemcore-verify>=0.3.0
27
+ Requires-Dist: cryptography>=41
28
+ Dynamic: license-file
29
+
30
+ # idemcore (Python SDK)
31
+
32
+ Python client for the [Idemcore](https://idemcore.net) API. Every chat answer comes
33
+ with a signed receipt, and this package can check that receipt on your own machine.
34
+
35
+ * Chat completions (plain or streaming) that return the answer text **and** its receipt id.
36
+ * Fetch a receipt and its public token log.
37
+ * Verify receipts locally: Ed25519 signature (L1), token-log chain (L2), and your
38
+ prompt / answer against the receipt's salted commitments (disclosure).
39
+ * Server-side checks: remote verify, replay on an independent build (L3), audit export.
40
+ * Sync and asyncio clients. Python 3.9+. Dependencies: `idemcore-verify` and `cryptography`.
41
+
42
+ Decoding is greedy: **temperature is fixed at 0** (and `n` at 1). Sending any other
43
+ temperature raises `InvalidRequestError` before a request is made.
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pip install idemcore
49
+ ```
50
+
51
+ ## Get an API key
52
+
53
+ 1. Sign in to the console at <https://console.idemcore.net/#/login> (the public
54
+ preview is free, with preview limits).
55
+ 2. Create an API key. It starts with `ick_` and is shown only once.
56
+ 3. Export it:
57
+
58
+ ```sh
59
+ export IDEMCORE_API_KEY="ick_..."
60
+ ```
61
+
62
+ The client reads `IDEMCORE_API_KEY` (and, optionally, `IDEMCORE_BASE_URL`, default
63
+ `https://api.idemcore.net`). You can also pass `Idemcore(api_key=...)`.
64
+
65
+ ## Quickstart
66
+
67
+ ```python
68
+ from idemcore import Idemcore
69
+
70
+ client = Idemcore()
71
+
72
+ r = client.chat("Name the three primary colours.", max_tokens=64)
73
+ print(r.text)
74
+ print(r.receipt_id) # rcpt_...
75
+
76
+ # Verify on this machine: signature, token-log chain, and that the receipt
77
+ # commits to exactly this prompt and this answer.
78
+ result = client.verify_local(r)
79
+ print(result.ok) # True
80
+ for name, level in result.levels.items():
81
+ print(name, level.ok, level.errors)
82
+ ```
83
+
84
+ `r.receipt` is the full signed receipt, `r.salt` the base64url salt of a
85
+ `hash_only` receipt (the default privacy mode). Anyone with the salt and the text
86
+ can check the commitments, so keep the salt with the conversation.
87
+
88
+ ### Streaming
89
+
90
+ ```python
91
+ with client.chat_stream("Write a haiku about receipts.") as stream:
92
+ for chunk in stream:
93
+ print(chunk.delta, end="", flush=True)
94
+
95
+ print()
96
+ print(stream.result.receipt_id) # the receipt arrives with the last chunk
97
+ ```
98
+
99
+ ### Async
100
+
101
+ ```python
102
+ import asyncio
103
+ from idemcore import AsyncIdemcore
104
+
105
+ async def main():
106
+ client = AsyncIdemcore()
107
+ r = await client.chat("Hello")
108
+ print(r.text, r.receipt_id)
109
+
110
+ asyncio.run(main())
111
+ ```
112
+
113
+ ### Receipts, logs and server-side checks
114
+
115
+ ```python
116
+ receipt = client.get_receipt("rcpt_...") # GET /v1/receipts/{id}
117
+ log = client.get_receipt_log("rcpt_...") # GET /v1/receipts/{id}/log
118
+ print(log.log_format) # idemcore-log/r1-public
119
+
120
+ client.verify_remote(r.receipt, messages=r.messages, salt=r.salt,
121
+ output_token_ids=r.output_token_ids) # POST /v1/receipts/verify
122
+ client.replay_result(r) # POST /v1/receipts/replay (L3)
123
+ lines = client.audit_export("2026-10-01", "2026-10-31") # POST /v1/audit/export (NDJSON lines)
124
+ client.models() # GET /v1/models
125
+ client.keys() # GET /v1/keys (signing keys, JWKS)
126
+ ```
127
+
128
+ ### Offline verification without the client
129
+
130
+ ```python
131
+ from idemcore.verify import verify_receipt
132
+
133
+ res = verify_receipt(receipt, log_text=log_text,
134
+ messages=messages, salt=salt, output_token_ids=output_ids)
135
+ ```
136
+
137
+ The production receipt-signing public key is bundled
138
+ (`idemcore.verify.DEFAULT_TRUSTED_KEYS`); pass `keys=client.keys()` to use the live
139
+ key set instead.
140
+
141
+ ## Errors
142
+
143
+ All errors derive from `idemcore.IdemcoreError` and carry `status`, `type` and
144
+ `message`: `AuthenticationError` (401), `PermissionDeniedError` (403, key lacks a
145
+ scope), `NotFoundError` (404), `ConflictError` (409), `InvalidRequestError` (400/415), `RateLimitError`
146
+ (429, see `.retry_after`), `InsufficientQuotaError` (429), `ServiceUnavailableError`
147
+ (503), `InternalServerError` (5xx), `APIConnectionError` (network). GET requests are
148
+ retried on 429/503 (`max_retries`, default 2); chat requests are never retried
149
+ automatically, so a retry never creates a second receipt behind your back.
150
+
151
+ ## Using the OpenAI SDK instead
152
+
153
+ The API is OpenAI-compatible, so `openai.OpenAI(base_url="https://api.idemcore.net/v1",
154
+ api_key=...)` also works; the receipt is in the response's `idemcore_receipt` field.
155
+ This package adds typed receipts and local verification.
156
+
157
+ ## Development
158
+
159
+ ```sh
160
+ pip install -e .
161
+ python -m unittest discover -s tests # mocked API, no network
162
+ IDEMCORE_LIVE=1 python -m unittest tests.test_client.LiveSmoke # GET /health only
163
+ ```
164
+
165
+ ## License
166
+
167
+ Apache-2.0
@@ -0,0 +1,12 @@
1
+ idemcore/__init__.py,sha256=nJ6-RZkaLp_P73CrEGaUZM6tMfuG8hA7ur7ZpwTPCFM,1182
2
+ idemcore/_types.py,sha256=VAAb42np799mWD88DYY8YvnnurZfLzkzQNOUqjDjVZc,1616
3
+ idemcore/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
4
+ idemcore/client.py,sha256=AIdirxQCMicsJY1II4ZkQ3NQ42st0w7kjeiIGVYSK78,22066
5
+ idemcore/errors.py,sha256=wRwUXTy-L3-MyPT8KedCHxvmvsQWM6XV31lw1Ey3O6Q,3058
6
+ idemcore/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ idemcore/verify.py,sha256=TO42kr-1eJ0J0mUKwtg40QsqMNgltJ8rtExToDdXVTg,16090
8
+ idemcore-0.1.0.dist-info/licenses/LICENSE,sha256=M7IiqWOs_rWTLS1hbDnGU3e5lhrKG1kyOjMT9MUzNtA,11339
9
+ idemcore-0.1.0.dist-info/METADATA,sha256=i6XZn0ONiJCEjpUC5xYUbKbjnajxo9fFkK3rD8M4T4s,5969
10
+ idemcore-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ idemcore-0.1.0.dist-info/top_level.txt,sha256=YB_A8U2PkxsxVPxjhuOVzqwcAEI9ryghmqdEZGXqC64,9
12
+ idemcore-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+