provedex-langchain 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.
- provedex_langchain-0.1.0/.gitignore +10 -0
- provedex_langchain-0.1.0/PKG-INFO +216 -0
- provedex_langchain-0.1.0/README.md +185 -0
- provedex_langchain-0.1.0/RELEASING.md +27 -0
- provedex_langchain-0.1.0/examples/langchain_basic.py +38 -0
- provedex_langchain-0.1.0/examples/langgraph_basic.py +52 -0
- provedex_langchain-0.1.0/pyproject.toml +71 -0
- provedex_langchain-0.1.0/src/provedex_langchain/__init__.py +7 -0
- provedex_langchain-0.1.0/src/provedex_langchain/_client.py +35 -0
- provedex_langchain-0.1.0/src/provedex_langchain/_state.py +45 -0
- provedex_langchain-0.1.0/src/provedex_langchain/config.py +38 -0
- provedex_langchain-0.1.0/src/provedex_langchain/handler.py +347 -0
- provedex_langchain-0.1.0/src/provedex_langchain/mapping.py +95 -0
- provedex_langchain-0.1.0/tests/__init__.py +0 -0
- provedex_langchain-0.1.0/tests/conftest.py +76 -0
- provedex_langchain-0.1.0/tests/test_async_smoke.py +73 -0
- provedex_langchain-0.1.0/tests/test_client.py +55 -0
- provedex_langchain-0.1.0/tests/test_config.py +40 -0
- provedex_langchain-0.1.0/tests/test_handler_async.py +59 -0
- provedex_langchain-0.1.0/tests/test_handler_sync.py +145 -0
- provedex_langchain-0.1.0/tests/test_integration.py +98 -0
- provedex_langchain-0.1.0/tests/test_mapping.py +119 -0
- provedex_langchain-0.1.0/tests/test_session.py +91 -0
- provedex_langchain-0.1.0/tests/test_state.py +48 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provedex-langchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain BaseCallbackHandler that signs every LLM and tool callback via the Provedex sidecar. Covers LangGraph by inheritance.
|
|
5
|
+
Project-URL: Homepage, https://github.com/provedex/provedex
|
|
6
|
+
Project-URL: Repository, https://github.com/provedex/provedex
|
|
7
|
+
Project-URL: Issues, https://github.com/provedex/provedex/issues
|
|
8
|
+
Author-email: Aditya Suresh <adi@provedex.io>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: audit,compliance,ed25519,langchain,langgraph,provedex,signing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: langchain-core<0.4,>=0.3
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: langchain-openai>=0.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: langchain<0.4,>=0.3; extra == 'dev'
|
|
24
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# provedex-langchain
|
|
33
|
+
|
|
34
|
+
`provedex-langchain` is a LangChain `BaseCallbackHandler` that signs every LLM
|
|
35
|
+
call, tool call, and operator session boundary via the Provedex sidecar. Each
|
|
36
|
+
event gets an Ed25519 signature and a SHA-256 parent hash, written to a local
|
|
37
|
+
NDJSON ledger that anyone with the operator's public key can verify offline.
|
|
38
|
+
The primary buyers are regulated-AI shops: healthcare scribes processing clinical
|
|
39
|
+
notes, financial bots executing trades or claims, and customer-service agents
|
|
40
|
+
subject to FINRA or state AI-act supervision. One `ProvedexCallbackHandler`
|
|
41
|
+
instance covers both LangChain LCEL pipelines and LangGraph state machines,
|
|
42
|
+
because LangGraph propagates LangChain callbacks for every LLM and tool step.
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install provedex-langchain
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Start the sidecar first (default `127.0.0.1:8765`):
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
provedex-agent --rate-limit-off &
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from provedex_langchain import ProvedexCallbackHandler, ProvedexConfig
|
|
58
|
+
|
|
59
|
+
handler = ProvedexCallbackHandler(config=ProvedexConfig())
|
|
60
|
+
# pass to your chain or graph:
|
|
61
|
+
chain.invoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Callback mapping
|
|
65
|
+
|
|
66
|
+
| LangChain callback(s) | AgentEvent variant | Fields populated |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `start_session()` (operator call) | `SessionStarted` | `agent_id`, `model_id`, `session_id` from config |
|
|
69
|
+
| `end_session(reason)` (operator call) | `SessionEnded` | `reason`, `summary_sha256 = sha256("")` |
|
|
70
|
+
| `on_llm_start` / `aon_llm_start` | none (buffered by `run_id`) | model id from `serialized.get("id")`, joined prompts, start timestamp |
|
|
71
|
+
| `on_chat_model_start` / `aon_chat_model_start` | none (buffered) | model id, flattened message list, start timestamp |
|
|
72
|
+
| `on_llm_end` / `aon_llm_end` (paired with start by `run_id`) | `ModelInvoked` | `model_id`, `prompt_sha256`, `response_sha256`, `prompt_tokens` / `response_tokens` from `llm_output.get("token_usage", {})` if present (else 0) |
|
|
73
|
+
| `on_llm_error` (paired) | `ModelInvoked` | `response_sha256 = sha256(f"{type(error).__name__}: {error}")` |
|
|
74
|
+
| `on_tool_start` / `aon_tool_start` | `ToolCalled` | `tool_name`, `args_sha256` of canonical-JSON of args, `args_redacted` |
|
|
75
|
+
| `on_tool_end` / `aon_tool_end` | `ToolReturned` | `tool_name`, `result_sha256`, `latency_ms`, `success = True` |
|
|
76
|
+
| `on_tool_error` | `ToolReturned` | `tool_name`, `result_sha256` of error description, `latency_ms`, `success = False` |
|
|
77
|
+
|
|
78
|
+
**Skipped** (not signed in v0.1): `on_llm_new_token` (per-token noise),
|
|
79
|
+
`on_chain_start` / `on_chain_end` (LCEL composition makes chain boundaries
|
|
80
|
+
ambiguous), `on_agent_action` / `on_agent_finish` (covered by tool events),
|
|
81
|
+
`on_retriever_start` / `on_retriever_end` (no v1 event variant), `on_text`
|
|
82
|
+
(no defined semantics).
|
|
83
|
+
|
|
84
|
+
## Configuration reference
|
|
85
|
+
|
|
86
|
+
| Field | Type | Default | Description |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `agent_url` | `str` | `$PROVEDEX_AGENT_URL` or `http://127.0.0.1:8765` | URL of the running `provedex-agent`. Override via env var `PROVEDEX_AGENT_URL` or constructor argument. |
|
|
89
|
+
| `session_id` | `str` | `uuid4()` | Identifier for this call session. Override to tie the ledger entry to your own session ID. |
|
|
90
|
+
| `agent_id` | `str` | `"langchain-agent"` | Logical name of your agent. Appears in every signed event for that session. |
|
|
91
|
+
| `model_id` | `str` | `"unknown"` | LLM model identifier. Used in `ModelInvoked` events when the callback args do not supply one. |
|
|
92
|
+
| `on_sign_failure` | `"warn" \| "raise" \| "silent"` | `"warn"` | What to do when the agent returns 4xx. `warn` logs a warning and continues. `raise` propagates the exception out of the background worker - useful in test environments. `silent` increments counters only. |
|
|
93
|
+
| `queue_size` | `int` | `1000` | Capacity of the internal deque. When full, the oldest queued event is dropped. |
|
|
94
|
+
| `request_timeout_seconds` | `float` | `2.0` | HTTP timeout for each POST to the agent. |
|
|
95
|
+
| `shutdown_drain_seconds` | `float` | `5.0` | How long to wait for the queue to drain after `handler.stop()` before returning. |
|
|
96
|
+
|
|
97
|
+
## Session lifecycle
|
|
98
|
+
|
|
99
|
+
A session groups a set of LLM and tool events under a single `SessionStarted` /
|
|
100
|
+
`SessionEnded` pair. The operator controls session boundaries - the handler does
|
|
101
|
+
not infer them from chain hierarchy.
|
|
102
|
+
|
|
103
|
+
Explicit form:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
handler.start_session()
|
|
107
|
+
chain.invoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
108
|
+
handler.end_session(reason="request_complete")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Context manager (sync):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
with handler.session("user-12345-request"):
|
|
115
|
+
chain.invoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Context manager (async):
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
async with handler.session("user-12345-request"):
|
|
122
|
+
await chain.ainvoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
On exception, the context manager calls `end_session` with `reason` set to the
|
|
126
|
+
exception class name, so the ledger always has a closed session boundary.
|
|
127
|
+
|
|
128
|
+
## Latency budget
|
|
129
|
+
|
|
130
|
+
The handler's hot path is a single `deque.appendleft()` call. The background
|
|
131
|
+
worker thread drains the deque and performs the HTTP POST off the LLM call
|
|
132
|
+
thread.
|
|
133
|
+
|
|
134
|
+
Measured against a 1ms-latency mock agent with a 1000-callback burst
|
|
135
|
+
(one `on_llm_start` + `on_llm_end` pair per iteration):
|
|
136
|
+
|
|
137
|
+
- p50 producer overhead: 2.5 microseconds
|
|
138
|
+
- p99 producer overhead: 5 microseconds
|
|
139
|
+
|
|
140
|
+
The LLM call thread is not blocked by network I/O.
|
|
141
|
+
|
|
142
|
+
## Failure modes
|
|
143
|
+
|
|
144
|
+
| Failure | Behaviour | Counter |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| Agent unreachable (ConnectionRefused) | warn + drop | `dropped_total` |
|
|
147
|
+
| Agent slow (timeout) | warn + drop | `dropped_total` |
|
|
148
|
+
| Agent 4xx | log error + apply `on_sign_failure` | `dropped_total` |
|
|
149
|
+
| Agent 5xx | warn + drop | `dropped_total` |
|
|
150
|
+
| Queue overflow | drop oldest, rate-limited warning | `overflow_total` |
|
|
151
|
+
| Callback with missing fields | log warning, skip enqueue | n/a |
|
|
152
|
+
| `run_id` missing on `on_llm_end` (no paired start) | log warning, skip emission | n/a |
|
|
153
|
+
|
|
154
|
+
Counters are readable as attributes on the handler instance:
|
|
155
|
+
`handler.signed_total`, `handler.dropped_total`, `handler.overflow_total`.
|
|
156
|
+
|
|
157
|
+
## LangGraph
|
|
158
|
+
|
|
159
|
+
LangGraph fires LangChain callbacks for every LLM and tool step inside a graph.
|
|
160
|
+
No additional integration is required. The operator wraps the graph invocation
|
|
161
|
+
inside a session context:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
async with handler.session("graph-run"):
|
|
165
|
+
await graph.invoke(state, config={"callbacks": [handler]})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Graph-specific events (CheckpointSaved, node enter / exit, edge transitions) are
|
|
169
|
+
NOT signed in v0.1. They are documented as a follow-up item once a customer
|
|
170
|
+
surfaces a concrete audit requirement for checkpoint-level granularity.
|
|
171
|
+
|
|
172
|
+
## Architecture
|
|
173
|
+
|
|
174
|
+
This binding does not contain the signing primitive. The primitive is the Rust
|
|
175
|
+
sidecar at https://github.com/provedex/provedex. The binding translates
|
|
176
|
+
LangChain callback arguments into `AgentEvent` shapes per
|
|
177
|
+
`docs/spec/event-schema-v1.md` and POSTs them to the sidecar over loopback
|
|
178
|
+
HTTP. The translation is pure Python with no C extensions required.
|
|
179
|
+
|
|
180
|
+
The sidecar signs each event with the operator's Ed25519 private key and chains
|
|
181
|
+
it via SHA-256 parent hashes into a local NDJSON ledger. Each event record
|
|
182
|
+
contains the signature, the parent hash, and the payload hash. Anyone with the
|
|
183
|
+
operator's public key can run `provedex verify` against the ledger file offline,
|
|
184
|
+
without network access and without trusting a third party.
|
|
185
|
+
|
|
186
|
+
## Verifying the ledger
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
provedex verify
|
|
190
|
+
provedex verify --ledger ~/.provedex/ledger.ndjson
|
|
191
|
+
provedex verify --ledger /path/to/sandboxed/ledger.ndjson
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The command reads the NDJSON file, checks the Ed25519 signature on every record,
|
|
195
|
+
and verifies the SHA-256 hash chain is unbroken. Exit code 0 means the ledger is
|
|
196
|
+
intact.
|
|
197
|
+
|
|
198
|
+
## Regulatory context
|
|
199
|
+
|
|
200
|
+
Tamper-evident audit logs are a direct requirement across several frameworks
|
|
201
|
+
currently in force or taking effect in 2026. The EU AI Act Article 12 requires
|
|
202
|
+
high-risk AI deployments to produce audit logs that are tamper-evident and
|
|
203
|
+
retained for at least six months; enforcement applies from August 2, 2026.
|
|
204
|
+
The Colorado AI Act (effective February 1, 2026) requires deployers of
|
|
205
|
+
high-risk AI systems to maintain records sufficient to demonstrate compliance
|
|
206
|
+
with consumer protection obligations. HIPAA's audit-control safeguard
|
|
207
|
+
(45 CFR 164.312(b)) requires clinical voice agents to record and examine
|
|
208
|
+
system activity, which for AI scribes means a verifiable transcript of every
|
|
209
|
+
utterance processed. FINRA's 2026 examination priorities identify AI agent
|
|
210
|
+
auditability as a focus area for broker-dealer supervision. A hash-chained,
|
|
211
|
+
Ed25519-signed ledger satisfies the tamper-evident requirement across all four
|
|
212
|
+
frameworks with a single integration point.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
License: Apache-2.0. Main repo: https://github.com/provedex/provedex
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# provedex-langchain
|
|
2
|
+
|
|
3
|
+
`provedex-langchain` is a LangChain `BaseCallbackHandler` that signs every LLM
|
|
4
|
+
call, tool call, and operator session boundary via the Provedex sidecar. Each
|
|
5
|
+
event gets an Ed25519 signature and a SHA-256 parent hash, written to a local
|
|
6
|
+
NDJSON ledger that anyone with the operator's public key can verify offline.
|
|
7
|
+
The primary buyers are regulated-AI shops: healthcare scribes processing clinical
|
|
8
|
+
notes, financial bots executing trades or claims, and customer-service agents
|
|
9
|
+
subject to FINRA or state AI-act supervision. One `ProvedexCallbackHandler`
|
|
10
|
+
instance covers both LangChain LCEL pipelines and LangGraph state machines,
|
|
11
|
+
because LangGraph propagates LangChain callbacks for every LLM and tool step.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pip install provedex-langchain
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Start the sidecar first (default `127.0.0.1:8765`):
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
provedex-agent --rate-limit-off &
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from provedex_langchain import ProvedexCallbackHandler, ProvedexConfig
|
|
27
|
+
|
|
28
|
+
handler = ProvedexCallbackHandler(config=ProvedexConfig())
|
|
29
|
+
# pass to your chain or graph:
|
|
30
|
+
chain.invoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Callback mapping
|
|
34
|
+
|
|
35
|
+
| LangChain callback(s) | AgentEvent variant | Fields populated |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `start_session()` (operator call) | `SessionStarted` | `agent_id`, `model_id`, `session_id` from config |
|
|
38
|
+
| `end_session(reason)` (operator call) | `SessionEnded` | `reason`, `summary_sha256 = sha256("")` |
|
|
39
|
+
| `on_llm_start` / `aon_llm_start` | none (buffered by `run_id`) | model id from `serialized.get("id")`, joined prompts, start timestamp |
|
|
40
|
+
| `on_chat_model_start` / `aon_chat_model_start` | none (buffered) | model id, flattened message list, start timestamp |
|
|
41
|
+
| `on_llm_end` / `aon_llm_end` (paired with start by `run_id`) | `ModelInvoked` | `model_id`, `prompt_sha256`, `response_sha256`, `prompt_tokens` / `response_tokens` from `llm_output.get("token_usage", {})` if present (else 0) |
|
|
42
|
+
| `on_llm_error` (paired) | `ModelInvoked` | `response_sha256 = sha256(f"{type(error).__name__}: {error}")` |
|
|
43
|
+
| `on_tool_start` / `aon_tool_start` | `ToolCalled` | `tool_name`, `args_sha256` of canonical-JSON of args, `args_redacted` |
|
|
44
|
+
| `on_tool_end` / `aon_tool_end` | `ToolReturned` | `tool_name`, `result_sha256`, `latency_ms`, `success = True` |
|
|
45
|
+
| `on_tool_error` | `ToolReturned` | `tool_name`, `result_sha256` of error description, `latency_ms`, `success = False` |
|
|
46
|
+
|
|
47
|
+
**Skipped** (not signed in v0.1): `on_llm_new_token` (per-token noise),
|
|
48
|
+
`on_chain_start` / `on_chain_end` (LCEL composition makes chain boundaries
|
|
49
|
+
ambiguous), `on_agent_action` / `on_agent_finish` (covered by tool events),
|
|
50
|
+
`on_retriever_start` / `on_retriever_end` (no v1 event variant), `on_text`
|
|
51
|
+
(no defined semantics).
|
|
52
|
+
|
|
53
|
+
## Configuration reference
|
|
54
|
+
|
|
55
|
+
| Field | Type | Default | Description |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| `agent_url` | `str` | `$PROVEDEX_AGENT_URL` or `http://127.0.0.1:8765` | URL of the running `provedex-agent`. Override via env var `PROVEDEX_AGENT_URL` or constructor argument. |
|
|
58
|
+
| `session_id` | `str` | `uuid4()` | Identifier for this call session. Override to tie the ledger entry to your own session ID. |
|
|
59
|
+
| `agent_id` | `str` | `"langchain-agent"` | Logical name of your agent. Appears in every signed event for that session. |
|
|
60
|
+
| `model_id` | `str` | `"unknown"` | LLM model identifier. Used in `ModelInvoked` events when the callback args do not supply one. |
|
|
61
|
+
| `on_sign_failure` | `"warn" \| "raise" \| "silent"` | `"warn"` | What to do when the agent returns 4xx. `warn` logs a warning and continues. `raise` propagates the exception out of the background worker - useful in test environments. `silent` increments counters only. |
|
|
62
|
+
| `queue_size` | `int` | `1000` | Capacity of the internal deque. When full, the oldest queued event is dropped. |
|
|
63
|
+
| `request_timeout_seconds` | `float` | `2.0` | HTTP timeout for each POST to the agent. |
|
|
64
|
+
| `shutdown_drain_seconds` | `float` | `5.0` | How long to wait for the queue to drain after `handler.stop()` before returning. |
|
|
65
|
+
|
|
66
|
+
## Session lifecycle
|
|
67
|
+
|
|
68
|
+
A session groups a set of LLM and tool events under a single `SessionStarted` /
|
|
69
|
+
`SessionEnded` pair. The operator controls session boundaries - the handler does
|
|
70
|
+
not infer them from chain hierarchy.
|
|
71
|
+
|
|
72
|
+
Explicit form:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
handler.start_session()
|
|
76
|
+
chain.invoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
77
|
+
handler.end_session(reason="request_complete")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Context manager (sync):
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
with handler.session("user-12345-request"):
|
|
84
|
+
chain.invoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Context manager (async):
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
async with handler.session("user-12345-request"):
|
|
91
|
+
await chain.ainvoke({"q": "hi"}, config={"callbacks": [handler]})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
On exception, the context manager calls `end_session` with `reason` set to the
|
|
95
|
+
exception class name, so the ledger always has a closed session boundary.
|
|
96
|
+
|
|
97
|
+
## Latency budget
|
|
98
|
+
|
|
99
|
+
The handler's hot path is a single `deque.appendleft()` call. The background
|
|
100
|
+
worker thread drains the deque and performs the HTTP POST off the LLM call
|
|
101
|
+
thread.
|
|
102
|
+
|
|
103
|
+
Measured against a 1ms-latency mock agent with a 1000-callback burst
|
|
104
|
+
(one `on_llm_start` + `on_llm_end` pair per iteration):
|
|
105
|
+
|
|
106
|
+
- p50 producer overhead: 2.5 microseconds
|
|
107
|
+
- p99 producer overhead: 5 microseconds
|
|
108
|
+
|
|
109
|
+
The LLM call thread is not blocked by network I/O.
|
|
110
|
+
|
|
111
|
+
## Failure modes
|
|
112
|
+
|
|
113
|
+
| Failure | Behaviour | Counter |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| Agent unreachable (ConnectionRefused) | warn + drop | `dropped_total` |
|
|
116
|
+
| Agent slow (timeout) | warn + drop | `dropped_total` |
|
|
117
|
+
| Agent 4xx | log error + apply `on_sign_failure` | `dropped_total` |
|
|
118
|
+
| Agent 5xx | warn + drop | `dropped_total` |
|
|
119
|
+
| Queue overflow | drop oldest, rate-limited warning | `overflow_total` |
|
|
120
|
+
| Callback with missing fields | log warning, skip enqueue | n/a |
|
|
121
|
+
| `run_id` missing on `on_llm_end` (no paired start) | log warning, skip emission | n/a |
|
|
122
|
+
|
|
123
|
+
Counters are readable as attributes on the handler instance:
|
|
124
|
+
`handler.signed_total`, `handler.dropped_total`, `handler.overflow_total`.
|
|
125
|
+
|
|
126
|
+
## LangGraph
|
|
127
|
+
|
|
128
|
+
LangGraph fires LangChain callbacks for every LLM and tool step inside a graph.
|
|
129
|
+
No additional integration is required. The operator wraps the graph invocation
|
|
130
|
+
inside a session context:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
async with handler.session("graph-run"):
|
|
134
|
+
await graph.invoke(state, config={"callbacks": [handler]})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Graph-specific events (CheckpointSaved, node enter / exit, edge transitions) are
|
|
138
|
+
NOT signed in v0.1. They are documented as a follow-up item once a customer
|
|
139
|
+
surfaces a concrete audit requirement for checkpoint-level granularity.
|
|
140
|
+
|
|
141
|
+
## Architecture
|
|
142
|
+
|
|
143
|
+
This binding does not contain the signing primitive. The primitive is the Rust
|
|
144
|
+
sidecar at https://github.com/provedex/provedex. The binding translates
|
|
145
|
+
LangChain callback arguments into `AgentEvent` shapes per
|
|
146
|
+
`docs/spec/event-schema-v1.md` and POSTs them to the sidecar over loopback
|
|
147
|
+
HTTP. The translation is pure Python with no C extensions required.
|
|
148
|
+
|
|
149
|
+
The sidecar signs each event with the operator's Ed25519 private key and chains
|
|
150
|
+
it via SHA-256 parent hashes into a local NDJSON ledger. Each event record
|
|
151
|
+
contains the signature, the parent hash, and the payload hash. Anyone with the
|
|
152
|
+
operator's public key can run `provedex verify` against the ledger file offline,
|
|
153
|
+
without network access and without trusting a third party.
|
|
154
|
+
|
|
155
|
+
## Verifying the ledger
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
provedex verify
|
|
159
|
+
provedex verify --ledger ~/.provedex/ledger.ndjson
|
|
160
|
+
provedex verify --ledger /path/to/sandboxed/ledger.ndjson
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The command reads the NDJSON file, checks the Ed25519 signature on every record,
|
|
164
|
+
and verifies the SHA-256 hash chain is unbroken. Exit code 0 means the ledger is
|
|
165
|
+
intact.
|
|
166
|
+
|
|
167
|
+
## Regulatory context
|
|
168
|
+
|
|
169
|
+
Tamper-evident audit logs are a direct requirement across several frameworks
|
|
170
|
+
currently in force or taking effect in 2026. The EU AI Act Article 12 requires
|
|
171
|
+
high-risk AI deployments to produce audit logs that are tamper-evident and
|
|
172
|
+
retained for at least six months; enforcement applies from August 2, 2026.
|
|
173
|
+
The Colorado AI Act (effective February 1, 2026) requires deployers of
|
|
174
|
+
high-risk AI systems to maintain records sufficient to demonstrate compliance
|
|
175
|
+
with consumer protection obligations. HIPAA's audit-control safeguard
|
|
176
|
+
(45 CFR 164.312(b)) requires clinical voice agents to record and examine
|
|
177
|
+
system activity, which for AI scribes means a verifiable transcript of every
|
|
178
|
+
utterance processed. FINRA's 2026 examination priorities identify AI agent
|
|
179
|
+
auditability as a focus area for broker-dealer supervision. A hash-chained,
|
|
180
|
+
Ed25519-signed ledger satisfies the tamper-evident requirement across all four
|
|
181
|
+
frameworks with a single integration point.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
License: Apache-2.0. Main repo: https://github.com/provedex/provedex
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Release process for provedex-langchain
|
|
2
|
+
|
|
3
|
+
Pre-release checklist:
|
|
4
|
+
|
|
5
|
+
1. All tests pass locally and in CI.
|
|
6
|
+
2. `pyproject.toml` version bumped if shipping a new version.
|
|
7
|
+
3. Tag the binding release: `git tag langchain-vX.Y.Z` (binding-scoped prefix so it does not collide with the agent's `vX.Y.Z` tags).
|
|
8
|
+
|
|
9
|
+
Publish to PyPI:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
cd bindings/python/provedex-langchain
|
|
13
|
+
python -m pip install --upgrade build twine
|
|
14
|
+
python -m build
|
|
15
|
+
python -m twine check dist/*
|
|
16
|
+
python -m twine upload dist/*
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
After publish:
|
|
20
|
+
|
|
21
|
+
1. Verify `pip install provedex-langchain` from a clean venv pulls the new version.
|
|
22
|
+
2. Confirm the README on PyPI renders correctly (long_description from `README.md`).
|
|
23
|
+
3. Update the `provedex-langchain` row in the root `README.md` Components table if anything material changed.
|
|
24
|
+
|
|
25
|
+
Yank policy: same as `provedex-pipecat`; see `bindings/python/provedex-pipecat/RELEASING.md` for the procedure.
|
|
26
|
+
|
|
27
|
+
Out of scope here: the Rust agent + CLI publish process lives in the root `RELEASING.md`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Minimal LangChain LCEL pipeline with Provedex signing.
|
|
2
|
+
|
|
3
|
+
Run a local provedex-agent before starting:
|
|
4
|
+
provedex-agent --rate-limit-off &
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
|
11
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
12
|
+
|
|
13
|
+
from provedex_langchain import ProvedexCallbackHandler, ProvedexConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def main() -> None:
|
|
17
|
+
cfg = ProvedexConfig(
|
|
18
|
+
agent_url=os.getenv("PROVEDEX_AGENT_URL", "http://127.0.0.1:8765"),
|
|
19
|
+
agent_id="example-langchain-agent",
|
|
20
|
+
model_id="fake-list",
|
|
21
|
+
session_id="example-session-001",
|
|
22
|
+
)
|
|
23
|
+
handler = ProvedexCallbackHandler(config=cfg)
|
|
24
|
+
|
|
25
|
+
# Replace FakeListChatModel with ChatOpenAI(model="gpt-4o") or any real LLM.
|
|
26
|
+
llm = FakeListChatModel(responses=["Hello back."])
|
|
27
|
+
prompt = ChatPromptTemplate.from_template("Say hi to {name}.")
|
|
28
|
+
chain = prompt | llm
|
|
29
|
+
|
|
30
|
+
async with handler.session("example-request"):
|
|
31
|
+
await chain.ainvoke({"name": "world"}, config={"callbacks": [handler]})
|
|
32
|
+
|
|
33
|
+
await handler.stop()
|
|
34
|
+
print(f"signed={handler.signed_total} dropped={handler.dropped_total}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Minimal LangGraph pipeline with Provedex signing.
|
|
2
|
+
|
|
3
|
+
Demonstrates that LangGraph users get audit coverage automatically because
|
|
4
|
+
LangGraph propagates LangChain callbacks for every LLM and tool call.
|
|
5
|
+
|
|
6
|
+
Run a local provedex-agent before starting:
|
|
7
|
+
provedex-agent --rate-limit-off &
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
|
15
|
+
from langgraph.graph import END, START, StateGraph
|
|
16
|
+
|
|
17
|
+
from provedex_langchain import ProvedexCallbackHandler, ProvedexConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class State(TypedDict):
|
|
21
|
+
answer: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def main() -> None:
|
|
25
|
+
cfg = ProvedexConfig(
|
|
26
|
+
agent_url=os.getenv("PROVEDEX_AGENT_URL", "http://127.0.0.1:8765"),
|
|
27
|
+
agent_id="example-langgraph-agent",
|
|
28
|
+
model_id="fake-list",
|
|
29
|
+
)
|
|
30
|
+
handler = ProvedexCallbackHandler(config=cfg)
|
|
31
|
+
|
|
32
|
+
llm = FakeListChatModel(responses=["Hello from the graph."])
|
|
33
|
+
|
|
34
|
+
async def respond(state: State, config) -> State:
|
|
35
|
+
resp = await llm.ainvoke("greet user", config=config)
|
|
36
|
+
return {"answer": resp.content}
|
|
37
|
+
|
|
38
|
+
graph_builder = StateGraph(State)
|
|
39
|
+
graph_builder.add_node("respond", respond)
|
|
40
|
+
graph_builder.add_edge(START, "respond")
|
|
41
|
+
graph_builder.add_edge("respond", END)
|
|
42
|
+
graph = graph_builder.compile()
|
|
43
|
+
|
|
44
|
+
async with handler.session("graph-example"):
|
|
45
|
+
await graph.ainvoke({"answer": ""}, config={"callbacks": [handler]})
|
|
46
|
+
|
|
47
|
+
await handler.stop()
|
|
48
|
+
print(f"signed={handler.signed_total} dropped={handler.dropped_total}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "provedex-langchain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain BaseCallbackHandler that signs every LLM and tool callback via the Provedex sidecar. Covers LangGraph by inheritance."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Aditya Suresh", email = "adi@provedex.io" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["langchain", "langgraph", "audit", "signing", "compliance", "ed25519", "provedex"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Security :: Cryptography",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"langchain-core>=0.3,<0.4",
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"pydantic>=2.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-asyncio>=0.23",
|
|
34
|
+
"respx>=0.21",
|
|
35
|
+
"ruff>=0.5",
|
|
36
|
+
"mypy>=1.10",
|
|
37
|
+
"langchain>=0.3,<0.4",
|
|
38
|
+
"langchain-openai>=0.2",
|
|
39
|
+
"langgraph>=0.2",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/provedex/provedex"
|
|
44
|
+
Repository = "https://github.com/provedex/provedex"
|
|
45
|
+
Issues = "https://github.com/provedex/provedex/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/provedex_langchain"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "B", "UP", "ASYNC"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint.per-file-ignores]
|
|
58
|
+
# Tests legitimately invoke cargo + provedex CLI via blocking subprocess.
|
|
59
|
+
"tests/*" = ["ASYNC221"]
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.11"
|
|
63
|
+
strict = true
|
|
64
|
+
ignore_missing_imports = true
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
markers = [
|
|
69
|
+
"integration: requires real provedex-agent binary",
|
|
70
|
+
"slow: takes > 1s",
|
|
71
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Private async HTTP client for the provedex-agent /v1/sign endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SignError(Exception):
|
|
11
|
+
"""Raised when a sign attempt fails (network, timeout, or non-2xx)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentClient:
|
|
15
|
+
"""Thin httpx wrapper. One per handler instance; reuses the connection."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_url: str, timeout: float) -> None:
|
|
18
|
+
self._base_url = base_url.rstrip("/")
|
|
19
|
+
self._client = httpx.AsyncClient(
|
|
20
|
+
base_url=self._base_url,
|
|
21
|
+
timeout=httpx.Timeout(timeout, connect=timeout),
|
|
22
|
+
headers={"content-type": "application/json"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def sign(self, event: dict[str, Any]) -> None:
|
|
26
|
+
"""POST {event: ...} to /v1/sign. Raises SignError on any failure."""
|
|
27
|
+
try:
|
|
28
|
+
resp = await self._client.post("/v1/sign", json={"event": event})
|
|
29
|
+
except httpx.HTTPError as e:
|
|
30
|
+
raise SignError(f"agent unreachable: {e}") from e
|
|
31
|
+
if resp.status_code >= 400:
|
|
32
|
+
raise SignError(f"agent returned {resp.status_code}: {resp.text[:200]}")
|
|
33
|
+
|
|
34
|
+
async def aclose(self) -> None:
|
|
35
|
+
await self._client.aclose()
|