prova-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,41 @@
1
+ # dependencies
2
+ node_modules/
3
+
4
+ # next.js
5
+ .next/
6
+ out/
7
+
8
+ # build
9
+ tsconfig.tsbuildinfo
10
+
11
+ # misc
12
+ .DS_Store
13
+ *.pem
14
+
15
+ # debug
16
+ npm-debug.log*
17
+
18
+ # env
19
+ .env
20
+ .env.local
21
+ .env*.local
22
+
23
+ # supabase local dev metadata
24
+ supabase/.temp/
25
+
26
+ # vercel
27
+ .vercel
28
+
29
+ # package lock
30
+ package-lock.json
31
+
32
+ # python
33
+ __pycache__/
34
+ *.py[cod]
35
+ *.pyo
36
+ .pytest_cache/
37
+
38
+ # playwright
39
+ /playwright-report/
40
+ /test-results/
41
+ /playwright/.cache/
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: prova-sdk
3
+ Version: 0.1.0
4
+ Summary: Agent-side SDK for the Prova AI control plane (ingest, gateway-check, register).
5
+ Project-URL: Homepage, https://prova.cobound.dev/docs/sdk
6
+ Project-URL: Documentation, https://prova.cobound.dev/docs/sdk
7
+ License: MIT
8
+ Keywords: agents,ai,audit,compliance,langgraph,llm,observability
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: cryptography>=42.0
19
+ Requires-Dist: httpx>=0.27
20
+ Provides-Extra: langgraph
21
+ Requires-Dist: langchain-core>=0.2; extra == 'langgraph'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # prova-sdk (Python)
25
+
26
+ Agent-side SDK for the Prova AI control plane. Thin wrappers around:
27
+
28
+ - `POST /api/v1/audit/ingest`
29
+ - `POST /api/v1/gateway/check`
30
+ - `POST /api/v1/inventory`
31
+
32
+ Plus an Ed25519 receipt verifier and a one-shot migration tool that bulk-imports
33
+ existing LangSmith / Langfuse / OpenAI logs into the Audit Vault.
34
+
35
+ Separate from the legacy `prova` package (the reasoning-chain verifier).
36
+ See `/docs/sdk` for guidance on which one to install.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ pip install prova-sdk
42
+ ```
43
+
44
+ Requires Python 3.10+.
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ from prova_cp import ProvaClient
50
+
51
+ prova = ProvaClient(api_key="prv_...")
52
+
53
+ prova.ingest({
54
+ "kind": "model_call",
55
+ "source": {"org_id": "YOUR_ORG", "framework": "langgraph", "app_id": "claims-orchestrator"},
56
+ "model": {"provider": "openai", "name": "gpt-4o"},
57
+ "payload": {"messages": messages, "response": response},
58
+ })
59
+
60
+ check = prova.gateway_check({"kind": "model_call", "payload": {"messages": messages}})
61
+ if check["action"] == "block":
62
+ raise PolicyBlocked(check["findings"])
63
+ ```
64
+
65
+ Pass `verify_receipts=True` to make the client verify every returned receipt's
66
+ Ed25519 signature against the published public key before returning.
67
+
68
+ ## LangGraph / LangChain auto-instrumentation
69
+
70
+ Install the optional extra and drop the callback handler into any graph. Every
71
+ LLM call, node, and tool call is ingested as a signed receipt automatically. No
72
+ per-node code changes.
73
+
74
+ ```sh
75
+ pip install "prova-sdk[langgraph]"
76
+ ```
77
+
78
+ ```python
79
+ from prova_cp import ProvaClient, ProvaCallbackHandler
80
+
81
+ prova = ProvaClient(api_key="prv_...")
82
+ handler = ProvaCallbackHandler(
83
+ prova,
84
+ app_id="claims-orchestrator",
85
+ environment="production",
86
+ framework="langgraph",
87
+ )
88
+
89
+ # LangGraph
90
+ graph.invoke(inputs, config={"callbacks": [handler]})
91
+
92
+ # LangChain
93
+ chain.invoke(inputs, config={"callbacks": [handler]})
94
+ ```
95
+
96
+ The handler is fail-silent: a Prova outage logs at warning level and never
97
+ breaks the agent. LLM calls become `model_call` receipts, graph nodes become
98
+ `agent_step`, tool calls become `tool_call`.
99
+
100
+ ## CrewAI
101
+
102
+ CrewAI has no LangChain-style callbacks; use its `step_callback` /
103
+ `task_callback` hooks instead.
104
+
105
+ ```python
106
+ from prova_cp import ProvaClient, ProvaCrewAI
107
+
108
+ tap = ProvaCrewAI(ProvaClient(api_key="prv_..."), app_id="research-crew")
109
+ crew = Crew(agents=[...], tasks=[...],
110
+ step_callback=tap.step_callback,
111
+ task_callback=tap.task_callback)
112
+ ```
113
+
114
+ Agent steps become `agent_step` receipts; completed tasks become `agent_run`.
115
+
116
+ ## Raw OpenAI / Anthropic clients (no framework)
117
+
118
+ Wrap the vendor client once. Every completion is mirrored to a signed receipt.
119
+ The vendor response is returned unchanged and a Prova failure never raises.
120
+
121
+ ```python
122
+ from openai import OpenAI
123
+ from prova_cp import ProvaClient, wrap_openai
124
+
125
+ client = wrap_openai(OpenAI(), ProvaClient(api_key="prv_..."), app_id="support-bot")
126
+ client.chat.completions.create(model="gpt-4o", messages=[...]) # auto-ingested
127
+ ```
128
+
129
+ `wrap_anthropic` is identical for the Anthropic SDK (`messages.create`).
130
+
131
+ ## Migrate existing logs
132
+
133
+ CLI:
134
+
135
+ ```sh
136
+ PROVA_API_KEY=prv_... prova-migrate --source langsmith --file runs.ndjson
137
+ ```
138
+
139
+ Programmatic:
140
+
141
+ ```python
142
+ from prova_cp import ProvaClient, migrate
143
+ from prova_cp.migrate import read_ndjson
144
+
145
+ with ProvaClient(api_key="prv_...") as client, open("observations.ndjson") as f:
146
+ result = migrate(client, "langfuse", read_ndjson(f))
147
+ print(result)
148
+ ```
149
+
150
+ Supported sources: `langsmith`, `langfuse`, `openai`. Idempotency keys are
151
+ derived from the source row id, so re-running the migration is safe.
152
+
153
+ ## Verify a receipt offline
154
+
155
+ ```python
156
+ from prova_cp import verify_receipt
157
+
158
+ verify_receipt(receipt, public_key_pem=PUBLIC_KEY_PEM)
159
+ ```
160
+
161
+ Or fetch the public key from the deployment automatically:
162
+
163
+ ```python
164
+ verify_receipt(receipt, base_url="https://api.prova.cobound.dev")
165
+ ```
@@ -0,0 +1,142 @@
1
+ # prova-sdk (Python)
2
+
3
+ Agent-side SDK for the Prova AI control plane. Thin wrappers around:
4
+
5
+ - `POST /api/v1/audit/ingest`
6
+ - `POST /api/v1/gateway/check`
7
+ - `POST /api/v1/inventory`
8
+
9
+ Plus an Ed25519 receipt verifier and a one-shot migration tool that bulk-imports
10
+ existing LangSmith / Langfuse / OpenAI logs into the Audit Vault.
11
+
12
+ Separate from the legacy `prova` package (the reasoning-chain verifier).
13
+ See `/docs/sdk` for guidance on which one to install.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ pip install prova-sdk
19
+ ```
20
+
21
+ Requires Python 3.10+.
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ from prova_cp import ProvaClient
27
+
28
+ prova = ProvaClient(api_key="prv_...")
29
+
30
+ prova.ingest({
31
+ "kind": "model_call",
32
+ "source": {"org_id": "YOUR_ORG", "framework": "langgraph", "app_id": "claims-orchestrator"},
33
+ "model": {"provider": "openai", "name": "gpt-4o"},
34
+ "payload": {"messages": messages, "response": response},
35
+ })
36
+
37
+ check = prova.gateway_check({"kind": "model_call", "payload": {"messages": messages}})
38
+ if check["action"] == "block":
39
+ raise PolicyBlocked(check["findings"])
40
+ ```
41
+
42
+ Pass `verify_receipts=True` to make the client verify every returned receipt's
43
+ Ed25519 signature against the published public key before returning.
44
+
45
+ ## LangGraph / LangChain auto-instrumentation
46
+
47
+ Install the optional extra and drop the callback handler into any graph. Every
48
+ LLM call, node, and tool call is ingested as a signed receipt automatically. No
49
+ per-node code changes.
50
+
51
+ ```sh
52
+ pip install "prova-sdk[langgraph]"
53
+ ```
54
+
55
+ ```python
56
+ from prova_cp import ProvaClient, ProvaCallbackHandler
57
+
58
+ prova = ProvaClient(api_key="prv_...")
59
+ handler = ProvaCallbackHandler(
60
+ prova,
61
+ app_id="claims-orchestrator",
62
+ environment="production",
63
+ framework="langgraph",
64
+ )
65
+
66
+ # LangGraph
67
+ graph.invoke(inputs, config={"callbacks": [handler]})
68
+
69
+ # LangChain
70
+ chain.invoke(inputs, config={"callbacks": [handler]})
71
+ ```
72
+
73
+ The handler is fail-silent: a Prova outage logs at warning level and never
74
+ breaks the agent. LLM calls become `model_call` receipts, graph nodes become
75
+ `agent_step`, tool calls become `tool_call`.
76
+
77
+ ## CrewAI
78
+
79
+ CrewAI has no LangChain-style callbacks; use its `step_callback` /
80
+ `task_callback` hooks instead.
81
+
82
+ ```python
83
+ from prova_cp import ProvaClient, ProvaCrewAI
84
+
85
+ tap = ProvaCrewAI(ProvaClient(api_key="prv_..."), app_id="research-crew")
86
+ crew = Crew(agents=[...], tasks=[...],
87
+ step_callback=tap.step_callback,
88
+ task_callback=tap.task_callback)
89
+ ```
90
+
91
+ Agent steps become `agent_step` receipts; completed tasks become `agent_run`.
92
+
93
+ ## Raw OpenAI / Anthropic clients (no framework)
94
+
95
+ Wrap the vendor client once. Every completion is mirrored to a signed receipt.
96
+ The vendor response is returned unchanged and a Prova failure never raises.
97
+
98
+ ```python
99
+ from openai import OpenAI
100
+ from prova_cp import ProvaClient, wrap_openai
101
+
102
+ client = wrap_openai(OpenAI(), ProvaClient(api_key="prv_..."), app_id="support-bot")
103
+ client.chat.completions.create(model="gpt-4o", messages=[...]) # auto-ingested
104
+ ```
105
+
106
+ `wrap_anthropic` is identical for the Anthropic SDK (`messages.create`).
107
+
108
+ ## Migrate existing logs
109
+
110
+ CLI:
111
+
112
+ ```sh
113
+ PROVA_API_KEY=prv_... prova-migrate --source langsmith --file runs.ndjson
114
+ ```
115
+
116
+ Programmatic:
117
+
118
+ ```python
119
+ from prova_cp import ProvaClient, migrate
120
+ from prova_cp.migrate import read_ndjson
121
+
122
+ with ProvaClient(api_key="prv_...") as client, open("observations.ndjson") as f:
123
+ result = migrate(client, "langfuse", read_ndjson(f))
124
+ print(result)
125
+ ```
126
+
127
+ Supported sources: `langsmith`, `langfuse`, `openai`. Idempotency keys are
128
+ derived from the source row id, so re-running the migration is safe.
129
+
130
+ ## Verify a receipt offline
131
+
132
+ ```python
133
+ from prova_cp import verify_receipt
134
+
135
+ verify_receipt(receipt, public_key_pem=PUBLIC_KEY_PEM)
136
+ ```
137
+
138
+ Or fetch the public key from the deployment automatically:
139
+
140
+ ```python
141
+ verify_receipt(receipt, base_url="https://api.prova.cobound.dev")
142
+ ```
@@ -0,0 +1,27 @@
1
+ """Agent-side SDK for the Prova AI control plane."""
2
+
3
+ from .client import ProvaClient, ProvaApiError, ReceiptVerificationError
4
+ from .verify import verify_receipt
5
+ from .canonical import canonicalize
6
+ from .migrate import migrate, MAPPERS, langsmith_mapper, langfuse_mapper, openai_mapper
7
+ from .callbacks import ProvaCallbackHandler
8
+ from .crewai import ProvaCrewAI
9
+ from .wrap import wrap_openai, wrap_anthropic
10
+
11
+ __all__ = [
12
+ "ProvaClient",
13
+ "ProvaApiError",
14
+ "ReceiptVerificationError",
15
+ "verify_receipt",
16
+ "canonicalize",
17
+ "migrate",
18
+ "MAPPERS",
19
+ "langsmith_mapper",
20
+ "langfuse_mapper",
21
+ "openai_mapper",
22
+ "ProvaCallbackHandler",
23
+ "ProvaCrewAI",
24
+ "wrap_openai",
25
+ "wrap_anthropic",
26
+ ]
27
+ __version__ = "0.1.0"
@@ -0,0 +1,302 @@
1
+ """LangChain / LangGraph callback handler for automatic Prova instrumentation.
2
+
3
+ Drop ProvaCallbackHandler into any LangGraph graph or LangChain chain and every
4
+ LLM call, chain invocation, and tool call is automatically ingested as a signed
5
+ Prova receipt.
6
+
7
+ Usage:
8
+
9
+ from prova_cp import ProvaClient
10
+ from prova_cp.callbacks import ProvaCallbackHandler
11
+
12
+ prova = ProvaClient(api_key=os.environ["PROVA_API_KEY"])
13
+ handler = ProvaCallbackHandler(
14
+ client=prova,
15
+ app_id="my-agent",
16
+ environment="production",
17
+ framework="langgraph",
18
+ )
19
+
20
+ # LangGraph:
21
+ graph.invoke(inputs, config={"callbacks": [handler]})
22
+
23
+ # LangChain:
24
+ chain.invoke(inputs, config={"callbacks": [handler]})
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import time
31
+ from typing import Any, Dict, List, Optional, Sequence, Union
32
+ from uuid import UUID
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ try:
37
+ from langchain_core.callbacks import BaseCallbackHandler
38
+ from langchain_core.outputs import LLMResult
39
+ _LANGCHAIN_AVAILABLE = True
40
+ except ImportError:
41
+ _LANGCHAIN_AVAILABLE = False
42
+
43
+ class BaseCallbackHandler: # type: ignore[no-redef]
44
+ """Stub so the module is importable even without langchain_core."""
45
+ pass
46
+
47
+ class LLMResult: # type: ignore[no-redef]
48
+ """Stub."""
49
+ generations: list = []
50
+ llm_output: dict = {}
51
+
52
+
53
+ class ProvaCallbackHandler(BaseCallbackHandler):
54
+ """LangChain/LangGraph callback handler that ingests every AI event into Prova.
55
+
56
+ Thread-safe: each run_id gets its own timing state stored in a dict.
57
+ Failures are swallowed and logged so a Prova outage never breaks the agent.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ client: Any,
63
+ *,
64
+ app_id: str = "agent",
65
+ environment: str = "production",
66
+ framework: str = "langgraph",
67
+ provider: Optional[str] = None,
68
+ ) -> None:
69
+ if not _LANGCHAIN_AVAILABLE:
70
+ raise ImportError(
71
+ "langchain-core is required to use ProvaCallbackHandler. "
72
+ "Install it with: pip install langchain-core"
73
+ )
74
+ super().__init__()
75
+ self._client = client
76
+ self._source = {
77
+ "app_id": app_id,
78
+ "environment": environment,
79
+ "framework": framework,
80
+ }
81
+ self._provider = provider
82
+ self._start_times: Dict[str, float] = {}
83
+ self._prompts: Dict[str, Any] = {}
84
+
85
+ # ------------------------------------------------------------------
86
+ # LLM callbacks
87
+ # ------------------------------------------------------------------
88
+
89
+ def on_llm_start(
90
+ self,
91
+ serialized: Dict[str, Any],
92
+ prompts: List[str],
93
+ *,
94
+ run_id: UUID,
95
+ parent_run_id: Optional[UUID] = None,
96
+ **kwargs: Any,
97
+ ) -> None:
98
+ key = str(run_id)
99
+ self._start_times[key] = time.time()
100
+ self._prompts[key] = prompts
101
+
102
+ def on_chat_model_start(
103
+ self,
104
+ serialized: Dict[str, Any],
105
+ messages: List[List[Any]],
106
+ *,
107
+ run_id: UUID,
108
+ parent_run_id: Optional[UUID] = None,
109
+ **kwargs: Any,
110
+ ) -> None:
111
+ key = str(run_id)
112
+ self._start_times[key] = time.time()
113
+ try:
114
+ self._prompts[key] = [
115
+ {"role": m.type, "content": m.content}
116
+ for batch in messages
117
+ for m in batch
118
+ ]
119
+ except Exception:
120
+ self._prompts[key] = str(messages)
121
+
122
+ def on_llm_end(
123
+ self,
124
+ response: LLMResult,
125
+ *,
126
+ run_id: UUID,
127
+ parent_run_id: Optional[UUID] = None,
128
+ **kwargs: Any,
129
+ ) -> None:
130
+ key = str(run_id)
131
+ elapsed_ms = int((time.time() - self._start_times.pop(key, time.time())) * 1000)
132
+ prompt = self._prompts.pop(key, None)
133
+
134
+ try:
135
+ generation = (
136
+ response.generations[0][0] if response.generations and response.generations[0] else None
137
+ )
138
+ completion = getattr(generation, "text", None) or (
139
+ getattr(generation, "message", None) and getattr(generation.message, "content", None)
140
+ )
141
+
142
+ llm_output = response.llm_output or {}
143
+ model_name = (
144
+ llm_output.get("model_name")
145
+ or llm_output.get("model")
146
+ or kwargs.get("invocation_params", {}).get("model_name")
147
+ or kwargs.get("invocation_params", {}).get("model")
148
+ )
149
+
150
+ payload: Dict[str, Any] = {"elapsed_ms": elapsed_ms}
151
+ if prompt is not None:
152
+ payload["prompt"] = prompt
153
+ if completion is not None:
154
+ payload["completion"] = completion
155
+ if llm_output:
156
+ payload["llm_output"] = llm_output
157
+
158
+ event: Dict[str, Any] = {
159
+ "kind": "model_call",
160
+ "source": {**self._source, "run_id": key},
161
+ "payload": payload,
162
+ }
163
+ if model_name or self._provider:
164
+ event["model"] = {
165
+ k: v for k, v in {
166
+ "provider": self._provider,
167
+ "name": model_name,
168
+ }.items() if v
169
+ }
170
+
171
+ self._client.ingest(event)
172
+ except Exception as exc:
173
+ log.warning("ProvaCallbackHandler.on_llm_end failed: %s", exc)
174
+
175
+ def on_llm_error(
176
+ self,
177
+ error: Union[Exception, KeyboardInterrupt],
178
+ *,
179
+ run_id: UUID,
180
+ parent_run_id: Optional[UUID] = None,
181
+ **kwargs: Any,
182
+ ) -> None:
183
+ key = str(run_id)
184
+ self._start_times.pop(key, None)
185
+ self._prompts.pop(key, None)
186
+
187
+ # ------------------------------------------------------------------
188
+ # Chain (agent node) callbacks
189
+ # ------------------------------------------------------------------
190
+
191
+ def on_chain_start(
192
+ self,
193
+ serialized: Dict[str, Any],
194
+ inputs: Dict[str, Any],
195
+ *,
196
+ run_id: UUID,
197
+ parent_run_id: Optional[UUID] = None,
198
+ **kwargs: Any,
199
+ ) -> None:
200
+ self._start_times[str(run_id)] = time.time()
201
+
202
+ def on_chain_end(
203
+ self,
204
+ outputs: Dict[str, Any],
205
+ *,
206
+ run_id: UUID,
207
+ parent_run_id: Optional[UUID] = None,
208
+ **kwargs: Any,
209
+ ) -> None:
210
+ key = str(run_id)
211
+ elapsed_ms = int((time.time() - self._start_times.pop(key, time.time())) * 1000)
212
+
213
+ if parent_run_id is None:
214
+ return
215
+
216
+ name = kwargs.get("name") or (
217
+ serialized.get("name") if (serialized := kwargs.get("serialized")) else None
218
+ )
219
+
220
+ try:
221
+ self._client.ingest({
222
+ "kind": "agent_step",
223
+ "source": {**self._source, "run_id": key, "parent_run_id": str(parent_run_id)},
224
+ "payload": {
225
+ "node": name or "unknown",
226
+ "outputs": _safe_truncate(outputs),
227
+ "elapsed_ms": elapsed_ms,
228
+ },
229
+ })
230
+ except Exception as exc:
231
+ log.warning("ProvaCallbackHandler.on_chain_end failed: %s", exc)
232
+
233
+ def on_chain_error(
234
+ self,
235
+ error: Union[Exception, KeyboardInterrupt],
236
+ *,
237
+ run_id: UUID,
238
+ parent_run_id: Optional[UUID] = None,
239
+ **kwargs: Any,
240
+ ) -> None:
241
+ self._start_times.pop(str(run_id), None)
242
+
243
+ # ------------------------------------------------------------------
244
+ # Tool callbacks
245
+ # ------------------------------------------------------------------
246
+
247
+ def on_tool_start(
248
+ self,
249
+ serialized: Dict[str, Any],
250
+ input_str: str,
251
+ *,
252
+ run_id: UUID,
253
+ parent_run_id: Optional[UUID] = None,
254
+ **kwargs: Any,
255
+ ) -> None:
256
+ self._start_times[str(run_id)] = time.time()
257
+
258
+ def on_tool_end(
259
+ self,
260
+ output: Any,
261
+ *,
262
+ run_id: UUID,
263
+ parent_run_id: Optional[UUID] = None,
264
+ **kwargs: Any,
265
+ ) -> None:
266
+ key = str(run_id)
267
+ elapsed_ms = int((time.time() - self._start_times.pop(key, time.time())) * 1000)
268
+
269
+ name = kwargs.get("name")
270
+ try:
271
+ self._client.ingest({
272
+ "kind": "tool_call",
273
+ "source": {**self._source, "run_id": key},
274
+ "payload": {
275
+ "tool": name or "unknown",
276
+ "output": _safe_truncate(output),
277
+ "elapsed_ms": elapsed_ms,
278
+ },
279
+ })
280
+ except Exception as exc:
281
+ log.warning("ProvaCallbackHandler.on_tool_end failed: %s", exc)
282
+
283
+ def on_tool_error(
284
+ self,
285
+ error: Union[Exception, KeyboardInterrupt],
286
+ *,
287
+ run_id: UUID,
288
+ parent_run_id: Optional[UUID] = None,
289
+ **kwargs: Any,
290
+ ) -> None:
291
+ self._start_times.pop(str(run_id), None)
292
+
293
+
294
+ def _safe_truncate(obj: Any, max_len: int = 2000) -> Any:
295
+ """Truncate large string values to keep receipt payloads reasonable."""
296
+ if isinstance(obj, str):
297
+ return obj[:max_len] + ("..." if len(obj) > max_len else "")
298
+ if isinstance(obj, dict):
299
+ return {k: _safe_truncate(v, max_len) for k, v in obj.items()}
300
+ if isinstance(obj, list):
301
+ return [_safe_truncate(v, max_len) for v in obj[:50]]
302
+ return obj
@@ -0,0 +1,15 @@
1
+ """Stable JSON canonicalization. Matches lib/receipts/sign.ts:canonicalize."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def canonicalize(value: Any) -> str:
10
+ if value is None or not isinstance(value, (dict, list)):
11
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
12
+ if isinstance(value, list):
13
+ return "[" + ",".join(canonicalize(v) for v in value) + "]"
14
+ keys = sorted(value.keys())
15
+ return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonicalize(value[k]) for k in keys) + "}"