intutic-clawde 1.8.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.
- intutic_clawde-1.8.0/PKG-INFO +21 -0
- intutic_clawde-1.8.0/README.md +3 -0
- intutic_clawde-1.8.0/intutic_clawde/__init__.py +4 -0
- intutic_clawde-1.8.0/intutic_clawde/budget_checker.py +42 -0
- intutic_clawde-1.8.0/intutic_clawde/circuit_breaker.py +45 -0
- intutic_clawde-1.8.0/intutic_clawde/client.py +138 -0
- intutic_clawde-1.8.0/intutic_clawde/context_resolver.py +36 -0
- intutic_clawde-1.8.0/intutic_clawde/errors.py +13 -0
- intutic_clawde-1.8.0/intutic_clawde/types.py +38 -0
- intutic_clawde-1.8.0/pyproject.toml +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: intutic-clawde
|
|
3
|
+
Version: 1.8.0
|
|
4
|
+
Summary: Programmatic proxy connection wrappers and warm-path budget gates for Intutic
|
|
5
|
+
Author: Intutic
|
|
6
|
+
Author-email: engineering@intutic.ai
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: requests (>=2.33.0,<3.0.0)
|
|
15
|
+
Requires-Dist: urllib3 (>=2.7.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# intutic-clawde
|
|
19
|
+
|
|
20
|
+
Python SDK for Intutic Agentic AI Governance proxy wrappers.
|
|
21
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import requests
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
from .errors import ClawdeConnectionError
|
|
5
|
+
|
|
6
|
+
class BudgetChecker:
|
|
7
|
+
def __init__(self, base_url: str, api_key: str):
|
|
8
|
+
self.base_url = base_url
|
|
9
|
+
self.api_key = api_key
|
|
10
|
+
self.cache: Dict[str, Dict[str, Any]] = {}
|
|
11
|
+
self.cache_ttl = 30.0 # 30s TTL
|
|
12
|
+
|
|
13
|
+
def check_budget(self, model: str, estimated_tokens: int) -> Dict[str, Any]:
|
|
14
|
+
cache_key = f"{model}:{estimated_tokens}"
|
|
15
|
+
now = time.time()
|
|
16
|
+
cached = self.cache.get(cache_key)
|
|
17
|
+
|
|
18
|
+
if cached and (now - cached["timestamp"]) < self.cache_ttl:
|
|
19
|
+
return cached["result"]
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
url = f"{self.base_url}/v1/budget/check"
|
|
23
|
+
params = {"model": model, "estimated_tokens": str(estimated_tokens)}
|
|
24
|
+
headers = {
|
|
25
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
26
|
+
"Accept": "application/json",
|
|
27
|
+
}
|
|
28
|
+
res = requests.get(url, params=params, headers=headers, timeout=5.0)
|
|
29
|
+
if res.status_code != 200:
|
|
30
|
+
raise Exception(f"Budget check returned status {res.status_code}")
|
|
31
|
+
result = res.json()
|
|
32
|
+
self.cache[cache_key] = {"result": result, "timestamp": now}
|
|
33
|
+
return result
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise ClawdeConnectionError(f"Could not reach budget check endpoint: {str(e)}")
|
|
36
|
+
|
|
37
|
+
def update_cached_budget(self, model: str, estimated_tokens: int, remaining_usd: float, allowed: bool) -> None:
|
|
38
|
+
cache_key = f"{model}:{estimated_tokens}"
|
|
39
|
+
self.cache[cache_key] = {
|
|
40
|
+
"result": {"allowed": allowed, "remaining_usd": remaining_usd},
|
|
41
|
+
"timestamp": time.time(),
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Callable, Any, Optional, TypeVar
|
|
3
|
+
from .errors import ClawdeVerdictError
|
|
4
|
+
|
|
5
|
+
T = TypeVar('T')
|
|
6
|
+
|
|
7
|
+
class CircuitBreaker:
|
|
8
|
+
def __init__(self, client: Any):
|
|
9
|
+
self.client = client
|
|
10
|
+
|
|
11
|
+
def wrap(self, tool_name: str, max_cost_usd: Optional[float] = None, fail_open: bool = False) -> Callable[[Callable[[], T]], T]:
|
|
12
|
+
def decorator(fn: Callable[[], T]) -> T:
|
|
13
|
+
# 1. Pre-Check: Context & pre-flight budget validation
|
|
14
|
+
try:
|
|
15
|
+
if max_cost_usd is not None:
|
|
16
|
+
budget = self.client.check_budget("default", 1)
|
|
17
|
+
if not budget.get("allowed", True):
|
|
18
|
+
raise ClawdeVerdictError(
|
|
19
|
+
"kill",
|
|
20
|
+
f"Circuit breaker tripped for tool '{tool_name}': budget exceeded. Remaining: ${budget.get('remaining_usd')}"
|
|
21
|
+
)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
if not fail_open:
|
|
24
|
+
raise e
|
|
25
|
+
if os.environ.get("INTUTIC_DEBUG") == "true":
|
|
26
|
+
print(f"[Clawde SDK] Circuit breaker pre-check failed (failing open): {str(e)}")
|
|
27
|
+
|
|
28
|
+
# 2. Execute the function
|
|
29
|
+
try:
|
|
30
|
+
result = fn()
|
|
31
|
+
|
|
32
|
+
# 3. Post-Check: If the result is a dict with verdict key, inspect it
|
|
33
|
+
if isinstance(result, dict) and result.get("verdict") == "kill":
|
|
34
|
+
raise ClawdeVerdictError("kill", "Execution blocked by governance policy (Verdict: KILL)")
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
except Exception as e:
|
|
38
|
+
if isinstance(e, ClawdeVerdictError) and not fail_open:
|
|
39
|
+
raise e
|
|
40
|
+
if not fail_open:
|
|
41
|
+
raise e
|
|
42
|
+
if os.environ.get("INTUTIC_DEBUG") == "true":
|
|
43
|
+
print(f"[Clawde SDK] Circuit breaker execution failed (failing open): {str(e)}")
|
|
44
|
+
return None # type: ignore
|
|
45
|
+
return decorator # type: ignore
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
import json
|
|
5
|
+
from typing import List, Dict, Any, Callable, Optional, Union
|
|
6
|
+
from .errors import ClawdeConnectionError, ClawdeVerdictError
|
|
7
|
+
from .context_resolver import resolve_context
|
|
8
|
+
from .budget_checker import BudgetChecker
|
|
9
|
+
from .circuit_breaker import CircuitBreaker
|
|
10
|
+
|
|
11
|
+
class ClawdeClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
api_key: str,
|
|
15
|
+
base_url: Optional[str] = None,
|
|
16
|
+
provider: Optional[str] = None,
|
|
17
|
+
auto_context: bool = True,
|
|
18
|
+
timeout: float = 30.0,
|
|
19
|
+
retries: int = 2
|
|
20
|
+
):
|
|
21
|
+
if not api_key:
|
|
22
|
+
raise ValueError("API key is required to initialize ClawdeClient.")
|
|
23
|
+
self.api_key = api_key
|
|
24
|
+
self.base_url = base_url or os.environ.get("INTUTIC_BASE_URL") or "http://localhost:4000"
|
|
25
|
+
self.provider = provider
|
|
26
|
+
self.auto_context = auto_context
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
self.retries = retries
|
|
29
|
+
|
|
30
|
+
self.budget_checker = BudgetChecker(self.base_url, self.api_key)
|
|
31
|
+
self.circuit_breaker_wrapper = CircuitBreaker(self)
|
|
32
|
+
self.listeners: Dict[str, List[Callable[[Dict[str, Any]], None]]] = {
|
|
33
|
+
"hijack": [], "enhance": [], "kill": [], "bypass": []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def on(self, event: str, callback: Callable[[Dict[str, Any]], None]) -> None:
|
|
37
|
+
if event in self.listeners:
|
|
38
|
+
self.listeners[event].append(callback)
|
|
39
|
+
|
|
40
|
+
def off(self, event: str, callback: Callable[[Dict[str, Any]], None]) -> None:
|
|
41
|
+
if event in self.listeners and callback in self.listeners[event]:
|
|
42
|
+
self.listeners[event].remove(callback)
|
|
43
|
+
|
|
44
|
+
def emit(self, event: str, payload: Dict[str, Any]) -> None:
|
|
45
|
+
if event in self.listeners:
|
|
46
|
+
for cb in self.listeners[event]:
|
|
47
|
+
try:
|
|
48
|
+
cb(payload)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def check_budget(self, model: str, estimated_tokens: int) -> Dict[str, Any]:
|
|
53
|
+
return self.budget_checker.check_budget(model, estimated_tokens)
|
|
54
|
+
|
|
55
|
+
def resolve_context(self) -> Dict[str, Any]:
|
|
56
|
+
if not self.auto_context:
|
|
57
|
+
return {}
|
|
58
|
+
return resolve_context()
|
|
59
|
+
|
|
60
|
+
def circuit_breaker(self, tool_name: str, max_cost_usd: Optional[float] = None, fail_open: bool = False) -> Callable[[Callable[[], Any]], Any]:
|
|
61
|
+
return self.circuit_breaker_wrapper.wrap(tool_name, max_cost_usd, fail_open)
|
|
62
|
+
|
|
63
|
+
def chat(self, model: str, messages: List[Dict[str, str]], **kwargs: Any) -> Dict[str, Any]:
|
|
64
|
+
# 1. Resolve context
|
|
65
|
+
context = self.resolve_context()
|
|
66
|
+
|
|
67
|
+
# 2. Prepare payload
|
|
68
|
+
request_payload = {
|
|
69
|
+
"model": model,
|
|
70
|
+
"messages": messages,
|
|
71
|
+
**kwargs
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# 3. Request logic with retries
|
|
75
|
+
max_attempts = self.retries + 1
|
|
76
|
+
last_error = None
|
|
77
|
+
|
|
78
|
+
for attempt in range(1, max_attempts + 1):
|
|
79
|
+
try:
|
|
80
|
+
headers = {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
83
|
+
"X-Intutic-Context": json.dumps(context),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if "max_cost_usd" in kwargs:
|
|
87
|
+
headers["X-Intutic-Cost-Limit"] = str(kwargs["max_cost_usd"])
|
|
88
|
+
if "sensitivity_tier" in kwargs:
|
|
89
|
+
headers["X-Intutic-Sensitivity"] = str(kwargs["sensitivity_tier"])
|
|
90
|
+
|
|
91
|
+
url = f"{self.base_url}/v1/chat/completions"
|
|
92
|
+
res = requests.post(url, json=request_payload, headers=headers, timeout=self.timeout)
|
|
93
|
+
|
|
94
|
+
if res.status_code != 200:
|
|
95
|
+
raise Exception(f"HTTP error {res.status_code}: {res.text}")
|
|
96
|
+
|
|
97
|
+
result = res.json()
|
|
98
|
+
|
|
99
|
+
# Extract headers
|
|
100
|
+
verdict = res.headers.get("x-intutic-verdict", "allow")
|
|
101
|
+
remaining = res.headers.get("x-intutic-budget-remaining")
|
|
102
|
+
pct = res.headers.get("x-intutic-budget-pct")
|
|
103
|
+
|
|
104
|
+
result["verdict"] = verdict
|
|
105
|
+
if remaining:
|
|
106
|
+
result["budget_remaining_usd"] = float(remaining)
|
|
107
|
+
if pct:
|
|
108
|
+
result["budget_pct_used"] = float(pct)
|
|
109
|
+
|
|
110
|
+
# Update budget cache
|
|
111
|
+
if "budget_remaining_usd" in result:
|
|
112
|
+
self.budget_checker.update_cached_budget(
|
|
113
|
+
model,
|
|
114
|
+
len(messages),
|
|
115
|
+
result["budget_remaining_usd"],
|
|
116
|
+
verdict != "kill"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Emit events
|
|
120
|
+
if verdict and verdict != "allow":
|
|
121
|
+
self.emit(verdict, result)
|
|
122
|
+
|
|
123
|
+
# Enforcement check
|
|
124
|
+
if verdict == "kill":
|
|
125
|
+
raise ClawdeVerdictError("kill", "Request blocked by policy (Verdict: KILL)")
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
except Exception as e:
|
|
129
|
+
last_error = e
|
|
130
|
+
if isinstance(e, ClawdeVerdictError):
|
|
131
|
+
raise e
|
|
132
|
+
if attempt < max_attempts:
|
|
133
|
+
if os.environ.get("INTUTIC_DEBUG") == "true":
|
|
134
|
+
print(f"[Clawde SDK] Attempt {attempt} failed, retrying... Error: {str(e)}")
|
|
135
|
+
time.sleep(attempt * 0.1)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
raise ClawdeConnectionError(f"Request failed after {max_attempts} attempts. Last error: {str(last_error)}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
|
|
6
|
+
def resolve_context() -> Dict[str, Any]:
|
|
7
|
+
config_path = Path.home() / ".intutic" / "config.json"
|
|
8
|
+
|
|
9
|
+
# 1. Primary: Read from sync-daemon config
|
|
10
|
+
if config_path.exists():
|
|
11
|
+
try:
|
|
12
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
13
|
+
config = json.load(f)
|
|
14
|
+
return {
|
|
15
|
+
"gitBranch": config.get("gitBranch"),
|
|
16
|
+
"jiraTicket": config.get("jiraTicket"),
|
|
17
|
+
"pagerdutyIncident": config.get("pagerdutyIncident"),
|
|
18
|
+
"ciPipeline": config.get("ciPipeline"),
|
|
19
|
+
"workingDirectory": config.get("workingDirectory", os.getcwd()),
|
|
20
|
+
"workspaceId": config.get("workspaceId"),
|
|
21
|
+
"sessionId": config.get("sessionId"),
|
|
22
|
+
}
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
# 2. Fallback: Environment variables
|
|
27
|
+
return {
|
|
28
|
+
"workspaceId": os.environ.get("INTUTIC_WORKSPACE_ID"),
|
|
29
|
+
"sessionId": os.environ.get("INTUTIC_SESSION_ID"),
|
|
30
|
+
"gitBranch": os.environ.get("GIT_BRANCH"),
|
|
31
|
+
"ciPipeline": os.environ.get("GITHUB_RUN_ID")
|
|
32
|
+
or os.environ.get("BUILDKITE_BUILD_ID")
|
|
33
|
+
or os.environ.get("CIRCLE_BUILD_NUM"),
|
|
34
|
+
"pagerdutyIncident": os.environ.get("PD_INCIDENT_ID"),
|
|
35
|
+
"workingDirectory": os.getcwd(),
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class ClawdeError(Exception):
|
|
2
|
+
"""Base exception for intutic-clawde SDK."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class ClawdeConnectionError(ClawdeError):
|
|
6
|
+
"""Raised when the SDK cannot reach the Intutic proxy."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class ClawdeVerdictError(ClawdeError):
|
|
10
|
+
"""Raised when the Intutic proxy returns a blocked verdict (KILL)."""
|
|
11
|
+
def __init__(self, verdict: str, message: str):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.verdict = verdict
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import TypedDict, List, Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class ClawdeClientOptions(TypedDict, total=False):
|
|
4
|
+
api_key: str
|
|
5
|
+
base_url: Optional[str]
|
|
6
|
+
provider: Optional[str]
|
|
7
|
+
auto_context: Optional[bool]
|
|
8
|
+
timeout: Optional[float]
|
|
9
|
+
retries: Optional[int]
|
|
10
|
+
|
|
11
|
+
class ChatMessage(TypedDict):
|
|
12
|
+
role: str
|
|
13
|
+
content: str
|
|
14
|
+
|
|
15
|
+
class ChatParams(TypedDict, total=False):
|
|
16
|
+
model: str
|
|
17
|
+
messages: List[ChatMessage]
|
|
18
|
+
temperature: Optional[float]
|
|
19
|
+
max_cost_usd: Optional[float]
|
|
20
|
+
sensitivity_tier: Optional[str]
|
|
21
|
+
|
|
22
|
+
class ChatResponse(Dict[str, Any]):
|
|
23
|
+
verdict: Optional[str]
|
|
24
|
+
budget_remaining_usd: Optional[float]
|
|
25
|
+
budget_pct_used: Optional[float]
|
|
26
|
+
|
|
27
|
+
class ResolvedContext(TypedDict, total=False):
|
|
28
|
+
gitBranch: Optional[str]
|
|
29
|
+
jiraTicket: Optional[str]
|
|
30
|
+
pagerdutyIncident: Optional[str]
|
|
31
|
+
ciPipeline: Optional[str]
|
|
32
|
+
workingDirectory: Optional[str]
|
|
33
|
+
workspaceId: Optional[str]
|
|
34
|
+
sessionId: Optional[str]
|
|
35
|
+
|
|
36
|
+
class BudgetCheckResult(TypedDict):
|
|
37
|
+
allowed: bool
|
|
38
|
+
remaining_usd: float
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "intutic-clawde"
|
|
3
|
+
version = "1.8.0"
|
|
4
|
+
description = "Programmatic proxy connection wrappers and warm-path budget gates for Intutic"
|
|
5
|
+
authors = ["Intutic <engineering@intutic.ai>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "intutic_clawde"}]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
# 3.9 reached end of life in October 2025. The patched releases of requests and
|
|
11
|
+
# urllib3 below both require >=3.10, so supporting 3.9 means shipping known CVEs.
|
|
12
|
+
python = "^3.10"
|
|
13
|
+
requests = "^2.33.0"
|
|
14
|
+
urllib3 = ">=2.7.0"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
pytest = "^9.0.3"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|