cadreen-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.
- cadreen_sdk-0.1.0/.gitignore +6 -0
- cadreen_sdk-0.1.0/PKG-INFO +11 -0
- cadreen_sdk-0.1.0/README.md +93 -0
- cadreen_sdk-0.1.0/cadreen/__init__.py +208 -0
- cadreen_sdk-0.1.0/cadreen/client.py +192 -0
- cadreen_sdk-0.1.0/cadreen/py.typed +0 -0
- cadreen_sdk-0.1.0/cadreen/redaction.py +71 -0
- cadreen_sdk-0.1.0/cadreen/resources/__init__.py +0 -0
- cadreen_sdk-0.1.0/cadreen/resources/connections.py +178 -0
- cadreen_sdk-0.1.0/cadreen/resources/executions.py +29 -0
- cadreen_sdk-0.1.0/cadreen/resources/guardrails.py +37 -0
- cadreen_sdk-0.1.0/cadreen/resources/intent.py +205 -0
- cadreen_sdk-0.1.0/cadreen/resources/memory.py +127 -0
- cadreen_sdk-0.1.0/cadreen/resources/policies.py +116 -0
- cadreen_sdk-0.1.0/cadreen/resources/traces.py +89 -0
- cadreen_sdk-0.1.0/cadreen/telemetry.py +157 -0
- cadreen_sdk-0.1.0/cadreen/types.py +894 -0
- cadreen_sdk-0.1.0/pyproject.toml +23 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cadreen-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Cadreen — intelligence-native automation infrastructure
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx-sse>=0.4
|
|
8
|
+
Requires-Dist: httpx>=0.25
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# cadreen-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for Cadreen — intelligence-native automation infrastructure.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cadreen-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
import asyncio
|
|
16
|
+
from cadreen import Cadreen
|
|
17
|
+
|
|
18
|
+
cadreen = Cadreen(api_key=os.environ["CADREEN_API_KEY"])
|
|
19
|
+
|
|
20
|
+
async def main():
|
|
21
|
+
# Intent — the main entry point
|
|
22
|
+
result = await cadreen.intent(
|
|
23
|
+
"Handle a refund request for invoice inv_123",
|
|
24
|
+
domain="support",
|
|
25
|
+
)
|
|
26
|
+
print(result.explain())
|
|
27
|
+
|
|
28
|
+
# Discriminated union with match/case
|
|
29
|
+
match result.type:
|
|
30
|
+
case "direct":
|
|
31
|
+
print(result.message.content)
|
|
32
|
+
case "clarify":
|
|
33
|
+
for q in result.questions:
|
|
34
|
+
print(q)
|
|
35
|
+
case "execution":
|
|
36
|
+
async for event in cadreen.executions.stream(result.execution["id"]):
|
|
37
|
+
print(event.type, event.data)
|
|
38
|
+
case "blocked":
|
|
39
|
+
print(result.policy["reason"])
|
|
40
|
+
case "connect_required":
|
|
41
|
+
print(result.connection["endpoint"])
|
|
42
|
+
|
|
43
|
+
# Memory — store knowledge
|
|
44
|
+
await cadreen.memory.remember(
|
|
45
|
+
type="reference",
|
|
46
|
+
content={"text": "GDPR Article 17...", "title": "GDPR Art. 17"},
|
|
47
|
+
authority=10,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Policies — governance guardrails
|
|
51
|
+
await cadreen.policies.require_approval("Refunds over $500 require human approval")
|
|
52
|
+
|
|
53
|
+
# Traces — intelligence observability
|
|
54
|
+
trace = await cadreen.traces.get(result.trace_id)
|
|
55
|
+
explanation = trace.explain()
|
|
56
|
+
print(explanation.summary)
|
|
57
|
+
|
|
58
|
+
# Connections — register external services
|
|
59
|
+
await cadreen.connections.register_openapi(
|
|
60
|
+
name="internal-erp",
|
|
61
|
+
spec_url="https://erp.example.com/openapi.json",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
cadreen = Cadreen(
|
|
71
|
+
api_key="...",
|
|
72
|
+
base_url="https://accomplishanything.today", # default, configurable
|
|
73
|
+
max_retries=2, # default 2
|
|
74
|
+
timeout=30, # default 30s
|
|
75
|
+
profile="lean", # optional: "lean" | "audit" | "full" (default "full")
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Response Profiles
|
|
80
|
+
|
|
81
|
+
Control how much intelligence metadata you get back:
|
|
82
|
+
|
|
83
|
+
| Profile | What you get | Use when |
|
|
84
|
+
|---------|-------------|----------|
|
|
85
|
+
| `"full"` (default) | Full intelligence envelope | You want full transparency |
|
|
86
|
+
| `"audit"` | Only governance decision + confidence + blocking gaps | You need to react to gates |
|
|
87
|
+
| `"lean"` | No envelope. Just `trace_id` | Hot-looping, minimal payload |
|
|
88
|
+
|
|
89
|
+
## Requirements
|
|
90
|
+
|
|
91
|
+
- Python 3.10+
|
|
92
|
+
- httpx >= 0.25
|
|
93
|
+
- httpx-sse >= 0.4
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from .client import CadreenError
|
|
2
|
+
from .types import (
|
|
3
|
+
CadreenConfig,
|
|
4
|
+
HealthStatus,
|
|
5
|
+
ConnectorType,
|
|
6
|
+
CapabilitySource,
|
|
7
|
+
TransportType,
|
|
8
|
+
EscalationStatus,
|
|
9
|
+
CredentialType,
|
|
10
|
+
AtomScope,
|
|
11
|
+
AtomType,
|
|
12
|
+
ErrorCategory,
|
|
13
|
+
RecoveryStrategyType,
|
|
14
|
+
StackItemSource,
|
|
15
|
+
StackItemStatus,
|
|
16
|
+
GovernanceDecisionType,
|
|
17
|
+
RecoveryStatus,
|
|
18
|
+
IntentMode,
|
|
19
|
+
Pagination,
|
|
20
|
+
Pathway,
|
|
21
|
+
ConnectionGroup,
|
|
22
|
+
ListConnectionsResponse,
|
|
23
|
+
AtomContent,
|
|
24
|
+
Atom,
|
|
25
|
+
CreateMemoryResponse,
|
|
26
|
+
SearchMemoryResponse,
|
|
27
|
+
Policy,
|
|
28
|
+
PolicyBundle,
|
|
29
|
+
GovernanceDecision,
|
|
30
|
+
EvaluatePolicyResponse,
|
|
31
|
+
CreatePolicyResponse,
|
|
32
|
+
ConfirmPolicyResponse,
|
|
33
|
+
Escalation,
|
|
34
|
+
ListEscalationsResponse,
|
|
35
|
+
CredentialMetadata,
|
|
36
|
+
ListCredentialsResponse,
|
|
37
|
+
CapabilityMatch,
|
|
38
|
+
ListCapabilitiesResponse,
|
|
39
|
+
ListPoliciesResponse,
|
|
40
|
+
Gap,
|
|
41
|
+
Outcome,
|
|
42
|
+
Assessment,
|
|
43
|
+
PolicyRecommendation,
|
|
44
|
+
StackItem,
|
|
45
|
+
StackBreakdown,
|
|
46
|
+
CapabilityTrace,
|
|
47
|
+
ReasoningTrace,
|
|
48
|
+
MemoryTrace,
|
|
49
|
+
GovernanceTrace,
|
|
50
|
+
HumilityTrace,
|
|
51
|
+
ProcessTrace,
|
|
52
|
+
IntelligenceMeta,
|
|
53
|
+
IntelligenceTraceEntry,
|
|
54
|
+
ListIntelligenceResponse,
|
|
55
|
+
IntelligenceStats,
|
|
56
|
+
IntentMessage,
|
|
57
|
+
IntentContext,
|
|
58
|
+
IntentRequest,
|
|
59
|
+
ResponseMessage,
|
|
60
|
+
TraceExplain,
|
|
61
|
+
ExecutionEvent,
|
|
62
|
+
ExecutionStatus,
|
|
63
|
+
IntentResult,
|
|
64
|
+
IntentResultType,
|
|
65
|
+
DirectResult,
|
|
66
|
+
ClarifyResult,
|
|
67
|
+
ExecutionResult,
|
|
68
|
+
BlockedResult,
|
|
69
|
+
ConnectRequiredResult,
|
|
70
|
+
RegisterOpenAPIRequest,
|
|
71
|
+
RegisterOpenAPIResponse,
|
|
72
|
+
RegisterMCPRequest,
|
|
73
|
+
RegisterMCPResponse,
|
|
74
|
+
InstallComposioRequest,
|
|
75
|
+
SearchMemoryRequest,
|
|
76
|
+
RememberRequest,
|
|
77
|
+
CreatePolicyRequest,
|
|
78
|
+
EvaluatePolicyRequest,
|
|
79
|
+
RequestOptions,
|
|
80
|
+
ConnectResult,
|
|
81
|
+
ConnectResultType,
|
|
82
|
+
ConnectPrebuiltDetail,
|
|
83
|
+
ConnectSchemaRequiredDetail,
|
|
84
|
+
ConnectManualDetail,
|
|
85
|
+
ConnectPathway,
|
|
86
|
+
ConnectUnknownDetail,
|
|
87
|
+
intent_status,
|
|
88
|
+
SetupRequest,
|
|
89
|
+
SetupResult,
|
|
90
|
+
SetupConnection,
|
|
91
|
+
SetupCredential,
|
|
92
|
+
SetupMemory,
|
|
93
|
+
SetupPolicy,
|
|
94
|
+
SetupConnectionResult,
|
|
95
|
+
SetupCredentialResult,
|
|
96
|
+
SetupMemoryResult,
|
|
97
|
+
SetupPolicyResult,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
from .client import HttpClient
|
|
101
|
+
from .resources.intent import IntentResource
|
|
102
|
+
from .resources.memory import MemoryResource
|
|
103
|
+
from .resources.policies import PoliciesResource
|
|
104
|
+
from .resources.connections import ConnectionsResource
|
|
105
|
+
from .resources.traces import TracesResource
|
|
106
|
+
from .resources.executions import ExecutionsResource
|
|
107
|
+
from .resources.guardrails import GuardrailsResource
|
|
108
|
+
from .redaction import redact_string, redact_trace, redact_messages, RedactOptions
|
|
109
|
+
from .telemetry import TelemetryProvider, TelemetrySpan, TelemetryMeter, OpenTelemetryAdapter, NoOpProvider
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Cadreen:
|
|
113
|
+
def __init__(self, api_key: str, base_url: str | None = None, max_retries: int | None = None, timeout: int | None = None, *, sandbox: bool = False, fixtures: dict | None = None) -> None:
|
|
114
|
+
config = CadreenConfig(api_key=api_key, base_url=base_url, max_retries=max_retries, timeout=timeout, sandbox=sandbox, fixtures=fixtures)
|
|
115
|
+
self._client = HttpClient(config)
|
|
116
|
+
self.intent = IntentResource(self._client)
|
|
117
|
+
self.memory = MemoryResource(self._client)
|
|
118
|
+
self.policies = PoliciesResource(self._client)
|
|
119
|
+
self.connections = ConnectionsResource(self._client)
|
|
120
|
+
self.traces = TracesResource(self._client)
|
|
121
|
+
self.executions = ExecutionsResource(self._client)
|
|
122
|
+
self.guardrails = GuardrailsResource(self.policies)
|
|
123
|
+
|
|
124
|
+
async def invoke(self, request: IntentRequest) -> IntentResult:
|
|
125
|
+
return await self.intent.invoke(request)
|
|
126
|
+
|
|
127
|
+
async def ask(
|
|
128
|
+
self,
|
|
129
|
+
prompt: str,
|
|
130
|
+
*,
|
|
131
|
+
conversation_id: str | None = None,
|
|
132
|
+
context: IntentContext | None = None,
|
|
133
|
+
stream: bool | None = None,
|
|
134
|
+
) -> IntentResult:
|
|
135
|
+
return await self.intent.invoke(
|
|
136
|
+
IntentRequest(
|
|
137
|
+
messages=[IntentMessage(role="user", content=prompt)],
|
|
138
|
+
mode="chat",
|
|
139
|
+
conversation_id=conversation_id,
|
|
140
|
+
context=context,
|
|
141
|
+
stream=stream,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def act(
|
|
146
|
+
self,
|
|
147
|
+
prompt: str,
|
|
148
|
+
*,
|
|
149
|
+
conversation_id: str | None = None,
|
|
150
|
+
context: IntentContext | None = None,
|
|
151
|
+
stream: bool | None = None,
|
|
152
|
+
) -> IntentResult:
|
|
153
|
+
return await self.intent.invoke(
|
|
154
|
+
IntentRequest(
|
|
155
|
+
messages=[IntentMessage(role="user", content=prompt)],
|
|
156
|
+
mode="execution",
|
|
157
|
+
conversation_id=conversation_id,
|
|
158
|
+
context=context,
|
|
159
|
+
stream=stream,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def remember(
|
|
164
|
+
self,
|
|
165
|
+
type: str,
|
|
166
|
+
content: dict,
|
|
167
|
+
*,
|
|
168
|
+
domain: str | None = None,
|
|
169
|
+
scope: str | None = None,
|
|
170
|
+
authority: int | None = None,
|
|
171
|
+
tags: list[str] | None = None,
|
|
172
|
+
) -> CreateMemoryResponse:
|
|
173
|
+
return await self.memory.remember(type, content, domain=domain, scope=scope, authority=authority, tags=tags)
|
|
174
|
+
|
|
175
|
+
async def context(self, request: SearchMemoryRequest) -> SearchMemoryResponse:
|
|
176
|
+
return await self.memory.search(request)
|
|
177
|
+
|
|
178
|
+
async def connect(self, capability: str) -> ConnectResult:
|
|
179
|
+
return await self.connections.connect(capability)
|
|
180
|
+
|
|
181
|
+
async def setup(self, request: SetupRequest) -> SetupResult:
|
|
182
|
+
payload: dict = {}
|
|
183
|
+
if request.connections:
|
|
184
|
+
payload["connections"] = [{"capability": c.capability} for c in request.connections]
|
|
185
|
+
if request.credentials:
|
|
186
|
+
payload["credentials"] = [{"provider": c.provider, "name": c.name, "key_data": c.key_data} for c in request.credentials]
|
|
187
|
+
if request.memory:
|
|
188
|
+
mem_items = []
|
|
189
|
+
for m in request.memory:
|
|
190
|
+
item: dict = {"content": m.content}
|
|
191
|
+
if m.type:
|
|
192
|
+
item["type"] = m.type
|
|
193
|
+
if m.domain:
|
|
194
|
+
item["domain"] = m.domain
|
|
195
|
+
if m.tags:
|
|
196
|
+
item["tags"] = m.tags
|
|
197
|
+
if m.authority:
|
|
198
|
+
item["authority"] = m.authority
|
|
199
|
+
mem_items.append(item)
|
|
200
|
+
payload["memory"] = mem_items
|
|
201
|
+
if request.policies:
|
|
202
|
+
payload["policies"] = [{"name": p.name, "rule": p.rule, "description": p.description, "severity": p.severity} for p in request.policies]
|
|
203
|
+
resp = await self._client.post("/api/v1/cadreen/setup", json=payload)
|
|
204
|
+
conns = [SetupConnectionResult(capability=c["capability"], status=c["status"], detail=c.get("detail"), error=c.get("error")) for c in resp.get("connections", [])]
|
|
205
|
+
creds = [SetupCredentialResult(provider=c["provider"], name=c.get("name", ""), status=c["status"], id=c.get("id"), error=c.get("error")) for c in resp.get("credentials", [])]
|
|
206
|
+
mems = [SetupMemoryResult(id=m["id"], type=m["type"], classified=m["classified"], status=m["status"], kind=m.get("kind"), error=m.get("error")) for m in resp.get("memory", [])]
|
|
207
|
+
pols = [SetupPolicyResult(name=p["name"], status=p["status"], id=p.get("id"), error=p.get("error")) for p in resp.get("policies", [])]
|
|
208
|
+
return SetupResult(connections=conns, credentials=creds, memory=mems, policies=pols, applied=resp["applied"], failed=resp["failed"])
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from urllib.parse import urlencode, quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from httpx_sse import aconnect_sse
|
|
10
|
+
|
|
11
|
+
from .types import CadreenConfig, RequestOptions
|
|
12
|
+
from .telemetry import TelemetryHooks, NoOpProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CadreenError(Exception):
|
|
16
|
+
status: int
|
|
17
|
+
code: str
|
|
18
|
+
error_type: str
|
|
19
|
+
details: Optional[list[dict[str, str]]]
|
|
20
|
+
intelligence: Optional[Any]
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
status: int,
|
|
25
|
+
code: str,
|
|
26
|
+
error_type: str,
|
|
27
|
+
message: str,
|
|
28
|
+
details: Optional[list[dict[str, str]]] = None,
|
|
29
|
+
intelligence: Optional[Any] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.status = status
|
|
33
|
+
self.code = code
|
|
34
|
+
self.error_type = error_type
|
|
35
|
+
self.details = details
|
|
36
|
+
self.intelligence = intelligence
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DEFAULT_BASE_URL = "https://accomplishanything.today"
|
|
40
|
+
DEFAULT_MAX_RETRIES = 2
|
|
41
|
+
DEFAULT_TIMEOUT = 30
|
|
42
|
+
RETRYABLE_STATUS_CODES = {408, 429, 502, 503, 504}
|
|
43
|
+
IDEMPOTENT_METHODS = {"GET", "PUT"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_query_string(params: dict[str, Any]) -> str:
|
|
47
|
+
parts: list[str] = []
|
|
48
|
+
for key, value in params.items():
|
|
49
|
+
if value is not None and value != "":
|
|
50
|
+
parts.append(f"{quote(key)}={quote(str(value))}")
|
|
51
|
+
return f"?{'&'.join(parts)}" if parts else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpClient:
|
|
55
|
+
def __init__(self, config: CadreenConfig) -> None:
|
|
56
|
+
self._base_url = (config.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
57
|
+
self._api_key = config.api_key
|
|
58
|
+
self._max_retries = config.max_retries if config.max_retries is not None else DEFAULT_MAX_RETRIES
|
|
59
|
+
self._timeout = config.timeout if config.timeout is not None else DEFAULT_TIMEOUT
|
|
60
|
+
self._sandbox = getattr(config, "sandbox", False) or False
|
|
61
|
+
self._fixtures = getattr(config, "fixtures", None) or {}
|
|
62
|
+
self._profile = getattr(config, "profile", None) or "full"
|
|
63
|
+
provider = config.telemetry if config.telemetry else NoOpProvider()
|
|
64
|
+
self._telemetry = TelemetryHooks(provider=provider)
|
|
65
|
+
|
|
66
|
+
async def request(
|
|
67
|
+
self,
|
|
68
|
+
method: str,
|
|
69
|
+
path: str,
|
|
70
|
+
body: Optional[Any] = None,
|
|
71
|
+
options: Optional[RequestOptions] = None,
|
|
72
|
+
) -> Any:
|
|
73
|
+
if self._sandbox:
|
|
74
|
+
fixture_key = f"{method} {path}"
|
|
75
|
+
if fixture_key in self._fixtures:
|
|
76
|
+
return self._fixtures[fixture_key]
|
|
77
|
+
if path in self._fixtures:
|
|
78
|
+
return self._fixtures[path]
|
|
79
|
+
raise CadreenError(404, "not_found", "not_found", f"No fixture for {fixture_key}. Provide fixtures via CadreenConfig.fixtures keyed by 'METHOD /path' or '/path'.")
|
|
80
|
+
|
|
81
|
+
url = f"{self._base_url}{path}"
|
|
82
|
+
span = self._telemetry.on_request_start(method, path)
|
|
83
|
+
start_time = time.monotonic()
|
|
84
|
+
headers: dict[str, str] = {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
87
|
+
"Accept": f'application/json; profile="{self._profile}"',
|
|
88
|
+
**(options.headers if options and options.headers else {}),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
92
|
+
headers["Idempotency-Key"] = (
|
|
93
|
+
(options.idempotency_key if options else None) or str(uuid.uuid4())
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
is_idempotent = method in IDEMPOTENT_METHODS or "Idempotency-Key" in headers
|
|
97
|
+
max_attempts = (self._max_retries + 1) if is_idempotent else 1
|
|
98
|
+
|
|
99
|
+
last_error: Optional[CadreenError] = None
|
|
100
|
+
|
|
101
|
+
for attempt in range(max_attempts):
|
|
102
|
+
if attempt > 0:
|
|
103
|
+
import asyncio
|
|
104
|
+
delay = min(1.0 * (2 ** (attempt - 1)), 10.0)
|
|
105
|
+
self._telemetry.on_retry(method, path, attempt)
|
|
106
|
+
await asyncio.sleep(delay)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
110
|
+
response = await client.request(
|
|
111
|
+
method=method,
|
|
112
|
+
url=url,
|
|
113
|
+
headers=headers,
|
|
114
|
+
json=body if body is not None else None,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not response.is_success:
|
|
118
|
+
error_body: Optional[dict[str, Any]] = None
|
|
119
|
+
try:
|
|
120
|
+
error_body = response.json()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
err = CadreenError(
|
|
125
|
+
status=response.status_code,
|
|
126
|
+
code=error_body.get("error", {}).get("code", "unknown") if error_body else "unknown",
|
|
127
|
+
error_type=error_body.get("error", {}).get("type", "error") if error_body else "error",
|
|
128
|
+
message=error_body.get("error", {}).get("message", response.text) if error_body else response.text,
|
|
129
|
+
details=error_body.get("error", {}).get("details") if error_body else None,
|
|
130
|
+
intelligence=error_body.get("intelligence") if error_body else None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if response.status_code in RETRYABLE_STATUS_CODES and is_idempotent and attempt < max_attempts - 1:
|
|
134
|
+
last_error = err
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
self._telemetry.on_error(span, err.code, err.error_type)
|
|
138
|
+
raise err
|
|
139
|
+
|
|
140
|
+
if response.status_code == 204:
|
|
141
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
142
|
+
self._telemetry.on_request_end(span, method, path, 204, duration_ms)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
146
|
+
self._telemetry.on_request_end(span, method, path, response.status_code, duration_ms)
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
except CadreenError:
|
|
150
|
+
raise
|
|
151
|
+
except httpx.TimeoutException:
|
|
152
|
+
if is_idempotent and attempt < max_attempts - 1:
|
|
153
|
+
last_error = CadreenError(408, "timeout", "timeout", "Request timed out")
|
|
154
|
+
continue
|
|
155
|
+
raise CadreenError(408, "timeout", "timeout", "Request timed out")
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
if is_idempotent and attempt < max_attempts - 1:
|
|
158
|
+
last_error = CadreenError(0, "network_error", "network", str(exc))
|
|
159
|
+
continue
|
|
160
|
+
raise CadreenError(0, "network_error", "network", str(exc))
|
|
161
|
+
|
|
162
|
+
raise last_error or CadreenError(0, "network_error", "network", "Request failed after retries")
|
|
163
|
+
|
|
164
|
+
async def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
165
|
+
qs = _build_query_string(params) if params else ""
|
|
166
|
+
return await self.request("GET", f"{path}{qs}")
|
|
167
|
+
|
|
168
|
+
async def post(self, path: str, body: Optional[Any] = None, options: Optional[RequestOptions] = None) -> Any:
|
|
169
|
+
return await self.request("POST", path, body, options)
|
|
170
|
+
|
|
171
|
+
async def put(self, path: str, body: Optional[Any] = None, options: Optional[RequestOptions] = None) -> Any:
|
|
172
|
+
return await self.request("PUT", path, body, options)
|
|
173
|
+
|
|
174
|
+
async def delete(self, path: str) -> Any:
|
|
175
|
+
return await self.request("DELETE", path)
|
|
176
|
+
|
|
177
|
+
async def stream(self, path: str) -> Any:
|
|
178
|
+
url = f"{self._base_url}{path}"
|
|
179
|
+
headers = {
|
|
180
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
181
|
+
"Accept": "text/event-stream",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
185
|
+
async with aconnect_sse(client, "GET", url, headers=headers) as event_source:
|
|
186
|
+
async for event in event_source.aiter_sse():
|
|
187
|
+
try:
|
|
188
|
+
import json
|
|
189
|
+
data = json.loads(event.data)
|
|
190
|
+
except Exception:
|
|
191
|
+
data = {"raw": event.data}
|
|
192
|
+
yield {"type": event.event or "message", "data": data}
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
|
|
8
|
+
_PHONE_RE = re.compile(r"(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}")
|
|
9
|
+
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
|
|
10
|
+
_API_KEY_RE = re.compile(r"sk_[a-zA-Z]+_[a-zA-Z0-9]{8,}")
|
|
11
|
+
_IP_RE = re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RedactOptions:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
preserve_uuids: bool = False,
|
|
19
|
+
keys_to_redact: Sequence[str] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.preserve_uuids = preserve_uuids
|
|
22
|
+
self.keys_to_redact = list(keys_to_redact) if keys_to_redact else [
|
|
23
|
+
"content", "message", "text", "body", "email", "phone", "address", "name",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def redact_string(text: str, options: RedactOptions | None = None) -> str:
|
|
28
|
+
opts = options or RedactOptions()
|
|
29
|
+
result = _EMAIL_RE.sub("[email]", text)
|
|
30
|
+
result = _PHONE_RE.sub("[phone]", result)
|
|
31
|
+
result = _API_KEY_RE.sub("[api_key]", result)
|
|
32
|
+
result = _IP_RE.sub("[ip]", result)
|
|
33
|
+
if not opts.preserve_uuids:
|
|
34
|
+
result = _UUID_RE.sub("[id]", result)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def redact_value(value: Any, options: RedactOptions | None = None) -> Any:
|
|
39
|
+
opts = options or RedactOptions()
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
return redact_string(value, opts)
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
return [redact_value(v, opts) for v in value]
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
result: dict[str, Any] = {}
|
|
46
|
+
for key, val in value.items():
|
|
47
|
+
if key.lower() in opts.keys_to_redact and isinstance(val, str):
|
|
48
|
+
result[key] = redact_string(val, opts)
|
|
49
|
+
else:
|
|
50
|
+
result[key] = redact_value(val, opts)
|
|
51
|
+
return result
|
|
52
|
+
if hasattr(value, "__dict__") and hasattr(value, "__dataclass_fields__"):
|
|
53
|
+
changes: dict[str, Any] = {}
|
|
54
|
+
for field_name in value.__dataclass_fields__:
|
|
55
|
+
field_val = getattr(value, field_name)
|
|
56
|
+
if isinstance(field_val, str) and field_name.lower() in opts.keys_to_redact:
|
|
57
|
+
changes[field_name] = redact_string(field_val, opts)
|
|
58
|
+
elif isinstance(field_val, (list, dict)) or hasattr(field_val, "__dataclass_fields__"):
|
|
59
|
+
changes[field_name] = redact_value(field_val, opts)
|
|
60
|
+
elif isinstance(field_val, str):
|
|
61
|
+
changes[field_name] = redact_string(field_val, opts)
|
|
62
|
+
return replace(value, **changes)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def redact_trace(intel: Any, options: RedactOptions | None = None) -> Any:
|
|
67
|
+
return redact_value(intel, options)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def redact_messages(messages: Sequence[Any], options: RedactOptions | None = None) -> list[Any]:
|
|
71
|
+
return [redact_value(msg, options) for msg in messages]
|
|
File without changes
|