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.
- prova_sdk-0.1.0/.gitignore +41 -0
- prova_sdk-0.1.0/PKG-INFO +165 -0
- prova_sdk-0.1.0/README.md +142 -0
- prova_sdk-0.1.0/prova_cp/__init__.py +27 -0
- prova_sdk-0.1.0/prova_cp/callbacks.py +302 -0
- prova_sdk-0.1.0/prova_cp/canonical.py +15 -0
- prova_sdk-0.1.0/prova_cp/cli.py +53 -0
- prova_sdk-0.1.0/prova_cp/client.py +116 -0
- prova_sdk-0.1.0/prova_cp/crewai.py +99 -0
- prova_sdk-0.1.0/prova_cp/migrate.py +185 -0
- prova_sdk-0.1.0/prova_cp/verify.py +97 -0
- prova_sdk-0.1.0/prova_cp/wrap.py +111 -0
- prova_sdk-0.1.0/pyproject.toml +39 -0
|
@@ -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/
|
prova_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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) + "}"
|