cheqpoint 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.
- cheqpoint/__init__.py +25 -0
- cheqpoint/client.py +437 -0
- cheqpoint/crewai_tool.py +86 -0
- cheqpoint/langchain_tool.py +109 -0
- cheqpoint-0.1.0.dist-info/METADATA +237 -0
- cheqpoint-0.1.0.dist-info/RECORD +8 -0
- cheqpoint-0.1.0.dist-info/WHEEL +5 -0
- cheqpoint-0.1.0.dist-info/top_level.txt +1 -0
cheqpoint/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
CheqpointClient,
|
|
3
|
+
CheqpointError,
|
|
4
|
+
RejectedError,
|
|
5
|
+
TimeoutError,
|
|
6
|
+
CheckpointResult,
|
|
7
|
+
RequestStatus,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CheqpointClient",
|
|
12
|
+
"CheqpointError",
|
|
13
|
+
"RejectedError",
|
|
14
|
+
"TimeoutError",
|
|
15
|
+
"CheckpointResult",
|
|
16
|
+
"RequestStatus",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
# Framework-specific tools are importable directly but not auto-imported
|
|
22
|
+
# to avoid requiring optional dependencies at package load time.
|
|
23
|
+
# Usage:
|
|
24
|
+
# from cheqpoint.langchain_tool import CheqpointApprovalTool
|
|
25
|
+
# from cheqpoint.crewai_tool import CheqpointApprovalTool
|
cheqpoint/client.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Cheqpoint Python SDK — human-in-the-loop approval queues for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import requests as _requests
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://app.cheqpoint.io"
|
|
14
|
+
DEFAULT_POLL_INTERVAL = 3 # seconds
|
|
15
|
+
DEFAULT_TIMEOUT = 300 # seconds (5 minutes)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Result types
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CheckpointResult:
|
|
25
|
+
id: str
|
|
26
|
+
status: str # "APPROVED" | "REJECTED"
|
|
27
|
+
details: dict[str, Any]
|
|
28
|
+
modified_details: Optional[dict[str, Any]]
|
|
29
|
+
response_notes: Optional[str]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def effective_details(self) -> dict[str, Any]:
|
|
33
|
+
"""Return modified_details if set, otherwise the original details."""
|
|
34
|
+
return self.modified_details if self.modified_details is not None else self.details
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RequestStatus:
|
|
39
|
+
id: str
|
|
40
|
+
status: str # "PENDING" | "APPROVED" | "REJECTED"
|
|
41
|
+
details: dict[str, Any]
|
|
42
|
+
modified_details: Optional[dict[str, Any]]
|
|
43
|
+
response_notes: Optional[str]
|
|
44
|
+
decision_reason_code: Optional[str]
|
|
45
|
+
decision_note: Optional[str]
|
|
46
|
+
auto_decided: bool
|
|
47
|
+
auto_decision_rule_name: Optional[str]
|
|
48
|
+
decided_at: Optional[str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Errors
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CheqpointError(Exception):
|
|
57
|
+
"""Raised when the Cheqpoint API returns an error response."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, status_code: int, response_body: Any = None) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.status_code = status_code
|
|
62
|
+
self.response_body = response_body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RejectedError(Exception):
|
|
66
|
+
"""Raised when a checkpoint request is rejected by a reviewer."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, request_id: str, response_notes: Optional[str]) -> None:
|
|
69
|
+
note = f": {response_notes}" if response_notes else ""
|
|
70
|
+
super().__init__(f"Cheqpoint request {request_id} was rejected{note}")
|
|
71
|
+
self.request_id = request_id
|
|
72
|
+
self.response_notes = response_notes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TimeoutError(Exception):
|
|
76
|
+
"""Raised when polling exceeds the configured timeout."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, request_id: str, timeout: float) -> None:
|
|
79
|
+
super().__init__(
|
|
80
|
+
f"Cheqpoint request {request_id} timed out after {timeout}s waiting for a decision"
|
|
81
|
+
)
|
|
82
|
+
self.request_id = request_id
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Client
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CheqpointClient:
|
|
92
|
+
"""Client for the Cheqpoint human-in-the-loop approval API."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
api_key: str,
|
|
97
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
98
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.api_key = api_key
|
|
101
|
+
self.base_url = base_url.rstrip("/")
|
|
102
|
+
self.default_timeout = timeout
|
|
103
|
+
self._session = _requests.Session()
|
|
104
|
+
self._session.headers.update(
|
|
105
|
+
{
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
"User-Agent": f"cheqpoint-python/{__version__}",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Public methods
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def checkpoint(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
action: str,
|
|
119
|
+
risk_score: Optional[float] = None,
|
|
120
|
+
summary: str,
|
|
121
|
+
details: dict[str, Any],
|
|
122
|
+
justification: Optional[str] = None,
|
|
123
|
+
webhook_url: Optional[str] = None,
|
|
124
|
+
trace: Optional[dict[str, Any]] = None,
|
|
125
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
126
|
+
timeout: Optional[float] = None,
|
|
127
|
+
) -> CheckpointResult:
|
|
128
|
+
"""Submit an action for review and block until a human decides.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
action: Action type (e.g. "send_email", "process_refund", "deploy_code").
|
|
132
|
+
risk_score: Optional float 0.0–1.0. Default: 0.5 (medium).
|
|
133
|
+
summary: One-sentence description shown to reviewers.
|
|
134
|
+
details: Structured payload for the action.
|
|
135
|
+
justification: Optional agent reasoning shown to reviewers.
|
|
136
|
+
webhook_url: URL for Cheqpoint to POST the decision to.
|
|
137
|
+
poll_interval: Seconds between status polls. Default 3.
|
|
138
|
+
timeout: Max seconds to wait. Default is client-level default (300).
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
CheckpointResult with status "APPROVED".
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
RejectedError: If the request is rejected.
|
|
145
|
+
TimeoutError: If no decision within `timeout` seconds.
|
|
146
|
+
CheqpointError: On API errors.
|
|
147
|
+
"""
|
|
148
|
+
effective_timeout = timeout if timeout is not None else self.default_timeout
|
|
149
|
+
|
|
150
|
+
result = self.create_request(
|
|
151
|
+
action=action,
|
|
152
|
+
risk_score=risk_score,
|
|
153
|
+
summary=summary,
|
|
154
|
+
details=details,
|
|
155
|
+
justification=justification,
|
|
156
|
+
webhook_url=webhook_url,
|
|
157
|
+
trace=trace,
|
|
158
|
+
)
|
|
159
|
+
request_id = result["id"]
|
|
160
|
+
deadline = time.monotonic() + effective_timeout
|
|
161
|
+
|
|
162
|
+
while time.monotonic() < deadline:
|
|
163
|
+
status = self.get_request(request_id)
|
|
164
|
+
|
|
165
|
+
if status.status == "APPROVED":
|
|
166
|
+
return CheckpointResult(
|
|
167
|
+
id=status.id,
|
|
168
|
+
status="APPROVED",
|
|
169
|
+
details=status.details,
|
|
170
|
+
modified_details=status.modified_details,
|
|
171
|
+
response_notes=status.response_notes,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if status.status == "REJECTED":
|
|
175
|
+
raise RejectedError(request_id, status.response_notes)
|
|
176
|
+
|
|
177
|
+
remaining = deadline - time.monotonic()
|
|
178
|
+
if remaining <= 0:
|
|
179
|
+
break
|
|
180
|
+
time.sleep(min(poll_interval, remaining))
|
|
181
|
+
|
|
182
|
+
raise TimeoutError(request_id, effective_timeout)
|
|
183
|
+
|
|
184
|
+
def create_request(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
action: str,
|
|
188
|
+
risk_score: Optional[float] = None,
|
|
189
|
+
summary: str,
|
|
190
|
+
details: dict[str, Any],
|
|
191
|
+
justification: Optional[str] = None,
|
|
192
|
+
webhook_url: Optional[str] = None,
|
|
193
|
+
trace: Optional[dict[str, Any]] = None,
|
|
194
|
+
) -> dict[str, str]:
|
|
195
|
+
"""Fire-and-forget: submit a request and return immediately with its ID.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
dict with "id" key containing the request ID.
|
|
199
|
+
"""
|
|
200
|
+
body: dict[str, Any] = {
|
|
201
|
+
"apiKey": self.api_key,
|
|
202
|
+
"action": action,
|
|
203
|
+
"riskScore": risk_score,
|
|
204
|
+
"summary": summary,
|
|
205
|
+
"details": details,
|
|
206
|
+
}
|
|
207
|
+
if justification is not None:
|
|
208
|
+
body["justification"] = justification
|
|
209
|
+
if webhook_url is not None:
|
|
210
|
+
body["webhookUrl"] = webhook_url
|
|
211
|
+
if trace is not None:
|
|
212
|
+
body["trace"] = trace
|
|
213
|
+
|
|
214
|
+
data = self._post("/api/approvals/request", body)
|
|
215
|
+
return {"id": data["id"]}
|
|
216
|
+
|
|
217
|
+
def get_request(self, request_id: str) -> RequestStatus:
|
|
218
|
+
"""Fetch the current status of a request by its ID."""
|
|
219
|
+
data = self._get(f"/api/approvals/{request_id}")
|
|
220
|
+
return RequestStatus(
|
|
221
|
+
id=data["id"],
|
|
222
|
+
status=data["status"],
|
|
223
|
+
details=data["details"],
|
|
224
|
+
modified_details=data.get("modifiedDetails"),
|
|
225
|
+
response_notes=data.get("responseNotes"),
|
|
226
|
+
decision_reason_code=data.get("decisionReasonCode"),
|
|
227
|
+
decision_note=data.get("decisionNote"),
|
|
228
|
+
auto_decided=data.get("autoDecided", False),
|
|
229
|
+
auto_decision_rule_name=data.get("autoDecisionRuleName"),
|
|
230
|
+
decided_at=data.get("decidedAt"),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# Instrumentation wrappers
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def wrap_openai(self, openai_client: Any) -> tuple[Any, "TraceCollector"]:
|
|
238
|
+
"""Wrap an OpenAI client to auto-capture traces.
|
|
239
|
+
|
|
240
|
+
Usage::
|
|
241
|
+
|
|
242
|
+
client, collector = cheq.wrap_openai(OpenAI(api_key=...))
|
|
243
|
+
response = client.chat.completions.create(model="gpt-4o", messages=[...])
|
|
244
|
+
cheq.checkpoint(..., trace=collector.get_trace())
|
|
245
|
+
"""
|
|
246
|
+
return _wrap_openai(openai_client)
|
|
247
|
+
|
|
248
|
+
def wrap_anthropic(self, anthropic_client: Any) -> tuple[Any, "TraceCollector"]:
|
|
249
|
+
"""Wrap an Anthropic client to auto-capture traces.
|
|
250
|
+
|
|
251
|
+
Usage::
|
|
252
|
+
|
|
253
|
+
client, collector = cheq.wrap_anthropic(Anthropic(api_key=...))
|
|
254
|
+
response = client.messages.create(model="claude-opus-4-6", messages=[...])
|
|
255
|
+
cheq.checkpoint(..., trace=collector.get_trace())
|
|
256
|
+
"""
|
|
257
|
+
return _wrap_anthropic(anthropic_client)
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# Internal helpers
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def _post(self, path: str, body: dict[str, Any]) -> Any:
|
|
264
|
+
url = f"{self.base_url}{path}"
|
|
265
|
+
resp = self._session.post(url, json=body)
|
|
266
|
+
return self._handle_response(resp)
|
|
267
|
+
|
|
268
|
+
def _get(self, path: str) -> Any:
|
|
269
|
+
url = f"{self.base_url}{path}"
|
|
270
|
+
resp = self._session.get(url)
|
|
271
|
+
return self._handle_response(resp)
|
|
272
|
+
|
|
273
|
+
def _handle_response(self, resp: _requests.Response) -> Any:
|
|
274
|
+
if not resp.ok:
|
|
275
|
+
body: Any = None
|
|
276
|
+
message = f"HTTP {resp.status_code}"
|
|
277
|
+
try:
|
|
278
|
+
body = resp.json()
|
|
279
|
+
if isinstance(body, dict) and "error" in body:
|
|
280
|
+
message = body["error"]
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
raise CheqpointError(message, resp.status_code, body)
|
|
284
|
+
return resp.json()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Trace instrumentation helpers
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
from datetime import datetime, timezone
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TraceCollector:
|
|
296
|
+
"""Accumulates trace steps; call get_trace() to build the TraceData dict."""
|
|
297
|
+
|
|
298
|
+
def __init__(self) -> None:
|
|
299
|
+
self._steps: list[dict[str, str]] = []
|
|
300
|
+
self._model: str = ""
|
|
301
|
+
self._total_tokens: int = 0
|
|
302
|
+
|
|
303
|
+
def _now(self) -> str:
|
|
304
|
+
return datetime.now(tz=timezone.utc).isoformat()
|
|
305
|
+
|
|
306
|
+
def add_step(self, type: str, content: str) -> None:
|
|
307
|
+
self._steps.append({"type": type, "content": content, "timestamp": self._now()})
|
|
308
|
+
|
|
309
|
+
def get_trace(self) -> dict[str, Any]:
|
|
310
|
+
trace: dict[str, Any] = {"steps": list(self._steps)}
|
|
311
|
+
if self._model:
|
|
312
|
+
trace["model"] = self._model
|
|
313
|
+
if self._total_tokens:
|
|
314
|
+
trace["totalTokens"] = self._total_tokens
|
|
315
|
+
return trace
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class _OpenAIWrapper:
|
|
319
|
+
"""Thin proxy around an OpenAI client that records chat completions."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, client: Any, collector: TraceCollector) -> None:
|
|
322
|
+
self._client = client
|
|
323
|
+
self._collector = collector
|
|
324
|
+
|
|
325
|
+
def __getattr__(self, name: str) -> Any:
|
|
326
|
+
if name == "chat":
|
|
327
|
+
return _OpenAIChatProxy(self._client.chat, self._collector)
|
|
328
|
+
return getattr(self._client, name)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class _OpenAIChatProxy:
|
|
332
|
+
def __init__(self, chat: Any, collector: TraceCollector) -> None:
|
|
333
|
+
self._chat = chat
|
|
334
|
+
self._collector = collector
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def completions(self) -> "_OpenAICompletionsProxy":
|
|
338
|
+
return _OpenAICompletionsProxy(self._chat.completions, self._collector)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class _OpenAICompletionsProxy:
|
|
342
|
+
def __init__(self, completions: Any, collector: TraceCollector) -> None:
|
|
343
|
+
self._completions = completions
|
|
344
|
+
self._collector = collector
|
|
345
|
+
|
|
346
|
+
def create(self, **kwargs: Any) -> Any:
|
|
347
|
+
if "model" in kwargs:
|
|
348
|
+
self._collector._model = kwargs["model"]
|
|
349
|
+
for msg in kwargs.get("messages", []):
|
|
350
|
+
role = msg.get("role", "")
|
|
351
|
+
content = msg.get("content", "")
|
|
352
|
+
if role == "assistant":
|
|
353
|
+
self._collector.add_step("thought", str(content))
|
|
354
|
+
elif role == "tool":
|
|
355
|
+
self._collector.add_step("observation", str(content))
|
|
356
|
+
if "tools" in kwargs:
|
|
357
|
+
self._collector.add_step("tool_call", str(kwargs["tools"])[:200])
|
|
358
|
+
|
|
359
|
+
result = self._completions.create(**kwargs)
|
|
360
|
+
|
|
361
|
+
usage = getattr(result, "usage", None)
|
|
362
|
+
if usage and hasattr(usage, "total_tokens") and usage.total_tokens:
|
|
363
|
+
self._collector._total_tokens += usage.total_tokens
|
|
364
|
+
|
|
365
|
+
choices = getattr(result, "choices", []) or []
|
|
366
|
+
if choices:
|
|
367
|
+
msg = getattr(choices[0], "message", None)
|
|
368
|
+
if msg:
|
|
369
|
+
if getattr(msg, "content", None):
|
|
370
|
+
self._collector.add_step("thought", msg.content)
|
|
371
|
+
tool_calls = getattr(msg, "tool_calls", None) or []
|
|
372
|
+
for tc in tool_calls:
|
|
373
|
+
fn = getattr(tc, "function", None)
|
|
374
|
+
name = getattr(fn, "name", "tool") if fn else "tool"
|
|
375
|
+
args = getattr(fn, "arguments", "") if fn else ""
|
|
376
|
+
self._collector.add_step("tool_call", f"{name}({args})")
|
|
377
|
+
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class _AnthropicWrapper:
|
|
382
|
+
"""Thin proxy around an Anthropic client that records message calls."""
|
|
383
|
+
|
|
384
|
+
def __init__(self, client: Any, collector: TraceCollector) -> None:
|
|
385
|
+
self._client = client
|
|
386
|
+
self._collector = collector
|
|
387
|
+
|
|
388
|
+
def __getattr__(self, name: str) -> Any:
|
|
389
|
+
if name == "messages":
|
|
390
|
+
return _AnthropicMessagesProxy(self._client.messages, self._collector)
|
|
391
|
+
return getattr(self._client, name)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class _AnthropicMessagesProxy:
|
|
395
|
+
def __init__(self, messages: Any, collector: TraceCollector) -> None:
|
|
396
|
+
self._messages = messages
|
|
397
|
+
self._collector = collector
|
|
398
|
+
|
|
399
|
+
def create(self, **kwargs: Any) -> Any:
|
|
400
|
+
if "model" in kwargs:
|
|
401
|
+
self._collector._model = kwargs["model"]
|
|
402
|
+
for msg in kwargs.get("messages", []):
|
|
403
|
+
role = msg.get("role", "")
|
|
404
|
+
content = msg.get("content", "")
|
|
405
|
+
if role == "assistant":
|
|
406
|
+
self._collector.add_step("thought", str(content))
|
|
407
|
+
elif role == "user":
|
|
408
|
+
self._collector.add_step("observation", str(content))
|
|
409
|
+
|
|
410
|
+
result = self._messages.create(**kwargs)
|
|
411
|
+
|
|
412
|
+
usage = getattr(result, "usage", None)
|
|
413
|
+
if usage:
|
|
414
|
+
inp = getattr(usage, "input_tokens", 0) or 0
|
|
415
|
+
out = getattr(usage, "output_tokens", 0) or 0
|
|
416
|
+
self._collector._total_tokens += inp + out
|
|
417
|
+
|
|
418
|
+
for block in getattr(result, "content", []) or []:
|
|
419
|
+
btype = getattr(block, "type", "")
|
|
420
|
+
if btype == "text":
|
|
421
|
+
self._collector.add_step("thought", getattr(block, "text", ""))
|
|
422
|
+
elif btype == "tool_use":
|
|
423
|
+
name = getattr(block, "name", "tool")
|
|
424
|
+
inp = getattr(block, "input", {})
|
|
425
|
+
self._collector.add_step("tool_call", f"{name}({inp})")
|
|
426
|
+
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _wrap_openai(client: Any) -> tuple[Any, TraceCollector]:
|
|
431
|
+
collector = TraceCollector()
|
|
432
|
+
return _OpenAIWrapper(client, collector), collector
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _wrap_anthropic(client: Any) -> tuple[Any, TraceCollector]:
|
|
436
|
+
collector = TraceCollector()
|
|
437
|
+
return _AnthropicWrapper(client, collector), collector
|
cheqpoint/crewai_tool.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cheqpoint CrewAI integration — human-in-the-loop approval tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .client import CheqpointClient
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from crewai.tools import BaseTool
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
except ImportError as e: # pragma: no cover
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"crewai is required for CheqpointApprovalTool. "
|
|
15
|
+
"Install it with: pip install cheqpoint[crewai]"
|
|
16
|
+
) from e
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ApprovalInput(BaseModel):
|
|
20
|
+
action: str = Field(
|
|
21
|
+
description="Category of the action (e.g. 'send_email', 'process_refund', 'deploy_code')"
|
|
22
|
+
)
|
|
23
|
+
summary: str = Field(description="One-sentence description shown to the reviewer")
|
|
24
|
+
details: dict[str, Any] = Field(description="Full structured payload for the action")
|
|
25
|
+
risk_score: float = Field(
|
|
26
|
+
default=0.5, ge=0.0, le=1.0, description="Risk score 0.0–1.0"
|
|
27
|
+
)
|
|
28
|
+
justification: Optional[str] = Field(
|
|
29
|
+
default=None, description="Optional agent reasoning for the reviewer"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CheqpointApprovalTool(BaseTool):
|
|
34
|
+
"""CrewAI tool that pauses agent execution and waits for human approval.
|
|
35
|
+
|
|
36
|
+
Usage::
|
|
37
|
+
|
|
38
|
+
from crewai import Agent, Task, Crew
|
|
39
|
+
from cheqpoint.crewai_tool import CheqpointApprovalTool
|
|
40
|
+
|
|
41
|
+
approval_tool = CheqpointApprovalTool(api_key="ck_...")
|
|
42
|
+
|
|
43
|
+
agent = Agent(
|
|
44
|
+
role="Financial Analyst",
|
|
45
|
+
goal="Process refunds with human oversight",
|
|
46
|
+
backstory="You process refunds and escalate risky ones for approval.",
|
|
47
|
+
tools=[approval_tool],
|
|
48
|
+
)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name: str = "request_human_approval"
|
|
52
|
+
description: str = (
|
|
53
|
+
"Request human approval before taking a consequential action. "
|
|
54
|
+
"Call this tool when an action needs human review before execution. "
|
|
55
|
+
"Provide action type, a clear summary, and full details. "
|
|
56
|
+
"The tool blocks until a human approves or rejects the request."
|
|
57
|
+
)
|
|
58
|
+
args_schema: type[BaseModel] = _ApprovalInput
|
|
59
|
+
|
|
60
|
+
_client: CheqpointClient
|
|
61
|
+
|
|
62
|
+
def __init__(self, api_key: str, base_url: str = "https://app.cheqpoint.io", **kwargs: Any) -> None:
|
|
63
|
+
super().__init__(**kwargs)
|
|
64
|
+
object.__setattr__(self, "_client", CheqpointClient(api_key=api_key, base_url=base_url))
|
|
65
|
+
|
|
66
|
+
def _run(
|
|
67
|
+
self,
|
|
68
|
+
action: str,
|
|
69
|
+
summary: str,
|
|
70
|
+
details: dict[str, Any],
|
|
71
|
+
risk_score: float = 0.5,
|
|
72
|
+
justification: Optional[str] = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Execute the approval request and return the decision as a string."""
|
|
75
|
+
try:
|
|
76
|
+
result = self._client.checkpoint(
|
|
77
|
+
action=action,
|
|
78
|
+
summary=summary,
|
|
79
|
+
details=details,
|
|
80
|
+
risk_score=risk_score,
|
|
81
|
+
justification=justification,
|
|
82
|
+
)
|
|
83
|
+
notes = result.response_notes or "Approved by reviewer"
|
|
84
|
+
return f"APPROVED: {notes}"
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
return f"REJECTED: {exc}"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Cheqpoint LangChain integration — human-in-the-loop approval tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Type
|
|
6
|
+
|
|
7
|
+
from .client import CheqpointClient
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from langchain.tools import BaseTool
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
except ImportError as e: # pragma: no cover
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"langchain is required for CheqpointApprovalTool. "
|
|
15
|
+
"Install it with: pip install cheqpoint[langchain]"
|
|
16
|
+
) from e
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _CheqpointInput(BaseModel):
|
|
20
|
+
action: str = Field(
|
|
21
|
+
description="Category of the action requiring approval (e.g. 'send_email', 'process_refund', 'deploy_code')"
|
|
22
|
+
)
|
|
23
|
+
summary: str = Field(
|
|
24
|
+
description="One-sentence description shown to the human reviewer"
|
|
25
|
+
)
|
|
26
|
+
details: dict[str, Any] = Field(
|
|
27
|
+
description="Structured payload describing the action in detail"
|
|
28
|
+
)
|
|
29
|
+
risk_score: float = Field(
|
|
30
|
+
default=0.5,
|
|
31
|
+
ge=0.0,
|
|
32
|
+
le=1.0,
|
|
33
|
+
description="Risk score from 0.0 (low) to 1.0 (critical). Default: 0.5",
|
|
34
|
+
)
|
|
35
|
+
justification: Optional[str] = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Optional agent reasoning shown to the reviewer",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CheqpointApprovalTool(BaseTool):
|
|
42
|
+
"""LangChain tool that pauses agent execution and waits for human approval.
|
|
43
|
+
|
|
44
|
+
Usage::
|
|
45
|
+
|
|
46
|
+
from langchain_openai import ChatOpenAI
|
|
47
|
+
from langchain.agents import create_tool_calling_agent, AgentExecutor
|
|
48
|
+
from cheqpoint.langchain_tool import CheqpointApprovalTool
|
|
49
|
+
|
|
50
|
+
tool = CheqpointApprovalTool(api_key="ck_...")
|
|
51
|
+
llm = ChatOpenAI(model="gpt-4o")
|
|
52
|
+
agent = create_tool_calling_agent(llm, [tool], prompt)
|
|
53
|
+
executor = AgentExecutor(agent=agent, tools=[tool])
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str = "request_human_approval"
|
|
57
|
+
description: str = (
|
|
58
|
+
"Request human approval before taking a consequential action. "
|
|
59
|
+
"Use this tool when the action could have significant impact and requires "
|
|
60
|
+
"a human reviewer to approve before you proceed. "
|
|
61
|
+
"The tool blocks until a decision is made."
|
|
62
|
+
)
|
|
63
|
+
args_schema: Type[BaseModel] = _CheqpointInput
|
|
64
|
+
|
|
65
|
+
# Pydantic excludes the client field from serialisation
|
|
66
|
+
_client: CheqpointClient
|
|
67
|
+
|
|
68
|
+
def __init__(self, api_key: str, base_url: str = "https://app.cheqpoint.io", **kwargs: Any) -> None:
|
|
69
|
+
super().__init__(**kwargs)
|
|
70
|
+
object.__setattr__(self, "_client", CheqpointClient(api_key=api_key, base_url=base_url))
|
|
71
|
+
|
|
72
|
+
def _run(
|
|
73
|
+
self,
|
|
74
|
+
action: str,
|
|
75
|
+
summary: str,
|
|
76
|
+
details: dict[str, Any],
|
|
77
|
+
risk_score: float = 0.5,
|
|
78
|
+
justification: Optional[str] = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Synchronous execution — submits the request and polls for decision."""
|
|
81
|
+
try:
|
|
82
|
+
result = self._client.checkpoint(
|
|
83
|
+
action=action,
|
|
84
|
+
summary=summary,
|
|
85
|
+
details=details,
|
|
86
|
+
risk_score=risk_score,
|
|
87
|
+
justification=justification,
|
|
88
|
+
)
|
|
89
|
+
notes = result.response_notes or "Approved by reviewer"
|
|
90
|
+
return f"APPROVED: {notes}"
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
return f"REJECTED: {exc}"
|
|
93
|
+
|
|
94
|
+
async def _arun(
|
|
95
|
+
self,
|
|
96
|
+
action: str,
|
|
97
|
+
summary: str,
|
|
98
|
+
details: dict[str, Any],
|
|
99
|
+
risk_score: float = 0.5,
|
|
100
|
+
justification: Optional[str] = None,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Async execution delegates to the sync implementation (polling is blocking)."""
|
|
103
|
+
return self._run(
|
|
104
|
+
action=action,
|
|
105
|
+
summary=summary,
|
|
106
|
+
details=details,
|
|
107
|
+
risk_score=risk_score,
|
|
108
|
+
justification=justification,
|
|
109
|
+
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cheqpoint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Human-in-the-loop approval SDK for AI agents
|
|
5
|
+
Home-page: https://cheqpoint.io
|
|
6
|
+
Author: Cheqpoint
|
|
7
|
+
Keywords: cheqpoint,human-in-the-loop,ai,agents,approval
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
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.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: requests>=2.28.0
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: classifier
|
|
22
|
+
Dynamic: description
|
|
23
|
+
Dynamic: description-content-type
|
|
24
|
+
Dynamic: home-page
|
|
25
|
+
Dynamic: keywords
|
|
26
|
+
Dynamic: requires-dist
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
Dynamic: summary
|
|
29
|
+
|
|
30
|
+
# cheqpoint
|
|
31
|
+
|
|
32
|
+
Official Python SDK for [Cheqpoint](https://cheqpoint.co) — human-in-the-loop approval queues for AI agents.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install cheqpoint
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from cheqpoint import CheqpointClient
|
|
45
|
+
|
|
46
|
+
client = CheqpointClient(api_key=os.environ["CHEQPOINT_API_KEY"])
|
|
47
|
+
|
|
48
|
+
# Your agent calls this instead of executing directly.
|
|
49
|
+
# checkpoint() submits the request and waits for a human decision.
|
|
50
|
+
result = client.checkpoint(
|
|
51
|
+
type="refund",
|
|
52
|
+
risk_level="high",
|
|
53
|
+
summary="Refund $149 to sarah@example.com",
|
|
54
|
+
details={"userId": "usr_123", "amount": 149, "currency": "USD"},
|
|
55
|
+
justification="Double-charged on invoice #1821",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# effective_details returns modifiedDetails if set, else original details
|
|
59
|
+
stripe.refunds.create(**result.effective_details)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Constructor
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
CheqpointClient(
|
|
66
|
+
api_key: str, # Required. Your workspace API key.
|
|
67
|
+
base_url: str = "https://app.cheqpoint.co",
|
|
68
|
+
timeout: float = 300, # Default poll timeout in seconds
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Methods
|
|
73
|
+
|
|
74
|
+
### `checkpoint(...)` → `CheckpointResult`
|
|
75
|
+
|
|
76
|
+
Submit an action for review and **block** until a human decides.
|
|
77
|
+
|
|
78
|
+
| Parameter | Type | Default | Description |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `type` | `str` | required | Short label for the action (e.g. `"refund"`, `"email"`) |
|
|
81
|
+
| `risk_level` | `"low" \| "medium" \| "high"` | required | Risk level of the action |
|
|
82
|
+
| `summary` | `str` | required | One-sentence description shown to reviewers |
|
|
83
|
+
| `details` | `dict` | required | Structured payload. Returned as-is (or modified) on approval |
|
|
84
|
+
| `justification` | `str \| None` | `None` | Agent's reasoning shown to reviewers |
|
|
85
|
+
| `webhook_url` | `str \| None` | `None` | URL for Cheqpoint to POST the decision to |
|
|
86
|
+
| `poll_interval` | `float` | `3` | Seconds between status polls |
|
|
87
|
+
| `timeout` | `float \| None` | client default | Max seconds to wait |
|
|
88
|
+
|
|
89
|
+
**Returns** `CheckpointResult` when approved.
|
|
90
|
+
**Raises** `RejectedError` if rejected.
|
|
91
|
+
**Raises** `TimeoutError` if no decision within timeout.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
@dataclass
|
|
95
|
+
class CheckpointResult:
|
|
96
|
+
id: str
|
|
97
|
+
status: str # "APPROVED"
|
|
98
|
+
details: dict # original payload
|
|
99
|
+
modified_details: dict | None # reviewer edits, if any
|
|
100
|
+
response_notes: str | None # reviewer's note
|
|
101
|
+
|
|
102
|
+
def effective_details(self) -> dict:
|
|
103
|
+
"""Returns modified_details if set, else original details."""
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `create_request(...)` → `dict`
|
|
107
|
+
|
|
108
|
+
Fire-and-forget. Submits the request and returns immediately with `{"id": "..."}`. Pair with a `webhook_url` or poll manually with `get_request`.
|
|
109
|
+
|
|
110
|
+
### `get_request(request_id)` → `RequestStatus`
|
|
111
|
+
|
|
112
|
+
Fetch the current status of a request.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@dataclass
|
|
116
|
+
class RequestStatus:
|
|
117
|
+
id: str
|
|
118
|
+
status: str # "PENDING" | "APPROVED" | "REJECTED"
|
|
119
|
+
type: str
|
|
120
|
+
risk_level: str
|
|
121
|
+
summary: str
|
|
122
|
+
details: dict
|
|
123
|
+
modified_details: dict | None
|
|
124
|
+
response_notes: str | None
|
|
125
|
+
webhook_delivered: bool
|
|
126
|
+
created_at: str
|
|
127
|
+
decided_at: str | None
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Error handling
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from cheqpoint import CheqpointClient, CheqpointError, RejectedError, TimeoutError
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = client.checkpoint(...)
|
|
137
|
+
except RejectedError as e:
|
|
138
|
+
print(f"Rejected: {e.response_notes}")
|
|
139
|
+
except TimeoutError as e:
|
|
140
|
+
print(f"Timed out for request {e.request_id}")
|
|
141
|
+
except CheqpointError as e:
|
|
142
|
+
print(f"API error {e.status_code}: {e}")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Examples
|
|
146
|
+
|
|
147
|
+
### With webhook (recommended for production)
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
result = client.create_request(
|
|
151
|
+
type="db-write",
|
|
152
|
+
risk_level="medium",
|
|
153
|
+
summary="Delete user account usr_456",
|
|
154
|
+
details={"userId": "usr_456", "reason": "GDPR deletion request"},
|
|
155
|
+
webhook_url="https://yourapp.com/webhook/cheqpoint",
|
|
156
|
+
)
|
|
157
|
+
request_id = result["id"]
|
|
158
|
+
# Store request_id — your Flask/Django webhook handler receives the decision
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Manual polling
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
result = client.create_request(type="email", risk_level="low", ...)
|
|
165
|
+
request_id = result["id"]
|
|
166
|
+
|
|
167
|
+
# Check later
|
|
168
|
+
status = client.get_request(request_id)
|
|
169
|
+
if status.status == "APPROVED":
|
|
170
|
+
payload = status.modified_details or status.details
|
|
171
|
+
send_email(**payload)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### LangChain agent tool
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from langchain_core.tools import tool
|
|
178
|
+
|
|
179
|
+
@tool
|
|
180
|
+
def issue_refund(user_id: str, amount: float, reason: str) -> str:
|
|
181
|
+
"""Issue a refund to a customer. Requires human approval."""
|
|
182
|
+
result = client.checkpoint(
|
|
183
|
+
type="refund",
|
|
184
|
+
risk_level="high",
|
|
185
|
+
summary=f"Refund ${amount} to user {user_id}",
|
|
186
|
+
details={"userId": user_id, "amount": amount, "reason": reason},
|
|
187
|
+
)
|
|
188
|
+
payload = result.effective_details
|
|
189
|
+
return f"Refund approved for ${payload['amount']}"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### CrewAI tool
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from crewai_tools import BaseTool
|
|
196
|
+
|
|
197
|
+
class CheqpointApprovalTool(BaseTool):
|
|
198
|
+
name: str = "Request Human Approval"
|
|
199
|
+
description: str = "Submit a risky action for human approval before executing."
|
|
200
|
+
|
|
201
|
+
def _run(self, action_type: str, summary: str, details: dict) -> str:
|
|
202
|
+
result = client.checkpoint(
|
|
203
|
+
type=action_type,
|
|
204
|
+
risk_level="high",
|
|
205
|
+
summary=summary,
|
|
206
|
+
details=details,
|
|
207
|
+
)
|
|
208
|
+
return f"Approved. Effective details: {result.effective_details}"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Flask webhook handler
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from flask import Flask, request, jsonify
|
|
215
|
+
|
|
216
|
+
app = Flask(__name__)
|
|
217
|
+
|
|
218
|
+
@app.route("/webhook/cheqpoint", methods=["POST"])
|
|
219
|
+
def cheqpoint_webhook():
|
|
220
|
+
data = request.get_json()
|
|
221
|
+
status = data["status"]
|
|
222
|
+
payload = data.get("modifiedDetails") or data["details"]
|
|
223
|
+
|
|
224
|
+
if status == "APPROVED":
|
|
225
|
+
process_action(payload)
|
|
226
|
+
|
|
227
|
+
return jsonify({"ok": True}), 200
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Requirements
|
|
231
|
+
|
|
232
|
+
- Python 3.9+
|
|
233
|
+
- `requests` library
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cheqpoint/__init__.py,sha256=GfWwQ84iw4v7hpzLvpJPr6TnTxlr3EPzDWxtBe7yicE,583
|
|
2
|
+
cheqpoint/client.py,sha256=vaHhkCb7M4TnRs2MKeRYrUTkUWRQUJLQarOW4tu3nQU,15457
|
|
3
|
+
cheqpoint/crewai_tool.py,sha256=0h73Uo-OAz7fhDYLJfgYKDRBDN0irI2jf7SdLzncETQ,2988
|
|
4
|
+
cheqpoint/langchain_tool.py,sha256=IHFd8-uMbUQIpMtwi9MivynFFKcCsVSNsF4wI6g2XbU,3676
|
|
5
|
+
cheqpoint-0.1.0.dist-info/METADATA,sha256=l2h-6yv1ekmgWvxTh5TRUcX1gNw8CR4SqmQPH3VsZTc,6693
|
|
6
|
+
cheqpoint-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
cheqpoint-0.1.0.dist-info/top_level.txt,sha256=V5bGw0B5vcbbkuBwrokEG1qt7clJRlIZewSLdDYaysk,10
|
|
8
|
+
cheqpoint-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cheqpoint
|