contextwall-sdk 0.1.1__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.
- contextwall_sdk/__init__.py +59 -0
- contextwall_sdk/_anthropic.py +269 -0
- contextwall_sdk/_openai.py +268 -0
- contextwall_sdk/client.py +475 -0
- contextwall_sdk/exceptions.py +53 -0
- contextwall_sdk-0.1.1.dist-info/METADATA +57 -0
- contextwall_sdk-0.1.1.dist-info/RECORD +8 -0
- contextwall_sdk-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""CRE SDK - drop-in context firewall for Anthropic and OpenAI agents.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
pip install 'contextwall-sdk[anthropic]'
|
|
6
|
+
|
|
7
|
+
from contextwall_sdk import SafeAnthropic, CREBlockedError
|
|
8
|
+
|
|
9
|
+
client = SafeAnthropic() # reads CRE_KEY and CRE_URL from env
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
response = client.messages.create(
|
|
13
|
+
model="claude-opus-4-5",
|
|
14
|
+
max_tokens=1024,
|
|
15
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
16
|
+
)
|
|
17
|
+
except CREBlockedError as e:
|
|
18
|
+
print(f"Blocked: {e.violations}")
|
|
19
|
+
|
|
20
|
+
Provisioning a key (admin)::
|
|
21
|
+
|
|
22
|
+
from contextwall_sdk import CREClient
|
|
23
|
+
|
|
24
|
+
cre = CREClient(api_key="...", base_url="http://localhost:8080")
|
|
25
|
+
result = cre.keys.create(
|
|
26
|
+
project_id="my-agent",
|
|
27
|
+
upstream_key="sk-ant-...",
|
|
28
|
+
)
|
|
29
|
+
print(result.key) # sk-cre-xxx - store this securely
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from .exceptions import CREError, CREBlockedError, CREUnavailableError, CREAuthError
|
|
33
|
+
from ._anthropic import SafeAnthropic, AsyncSafeAnthropic
|
|
34
|
+
from ._openai import SafeOpenAI, AsyncSafeOpenAI
|
|
35
|
+
from .client import CREClient, AsyncCREClient, Source, ProxyKeyResult, HealthStatus, AnalyticsSummary
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Exceptions
|
|
41
|
+
"CREError",
|
|
42
|
+
"CREBlockedError",
|
|
43
|
+
"CREUnavailableError",
|
|
44
|
+
"CREAuthError",
|
|
45
|
+
# Anthropic wrappers
|
|
46
|
+
"SafeAnthropic",
|
|
47
|
+
"AsyncSafeAnthropic",
|
|
48
|
+
# OpenAI wrappers
|
|
49
|
+
"SafeOpenAI",
|
|
50
|
+
"AsyncSafeOpenAI",
|
|
51
|
+
# Admin client
|
|
52
|
+
"CREClient",
|
|
53
|
+
"AsyncCREClient",
|
|
54
|
+
# Response models
|
|
55
|
+
"Source",
|
|
56
|
+
"ProxyKeyResult",
|
|
57
|
+
"HealthStatus",
|
|
58
|
+
"AnalyticsSummary",
|
|
59
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""SafeAnthropic - drop-in Anthropic client with CRE enforcement.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
# Before
|
|
6
|
+
import anthropic
|
|
7
|
+
client = anthropic.Anthropic(api_key="sk-ant-...")
|
|
8
|
+
|
|
9
|
+
# After (one line change, everything else identical)
|
|
10
|
+
from contextwall_sdk import SafeAnthropic
|
|
11
|
+
client = SafeAnthropic(cre_key="sk-cre-...", cre_url="http://localhost:8080")
|
|
12
|
+
|
|
13
|
+
# Same API
|
|
14
|
+
response = client.messages.create(
|
|
15
|
+
model="claude-opus-4-5",
|
|
16
|
+
max_tokens=1024,
|
|
17
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
Environment variables (no code changes needed)::
|
|
21
|
+
|
|
22
|
+
CRE_KEY=sk-cre-...
|
|
23
|
+
CRE_URL=http://localhost:8080
|
|
24
|
+
|
|
25
|
+
from contextwall_sdk import SafeAnthropic
|
|
26
|
+
client = SafeAnthropic() # reads from env
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from typing import Any, Iterator, AsyncIterator
|
|
33
|
+
|
|
34
|
+
from .exceptions import CREBlockedError, CREUnavailableError, CREAuthError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_cre_block(body: Any) -> None:
|
|
38
|
+
"""Raise CREBlockedError if the response body is a CRE policy violation."""
|
|
39
|
+
if not isinstance(body, dict):
|
|
40
|
+
return
|
|
41
|
+
err = body.get("error", {})
|
|
42
|
+
if isinstance(err, dict) and err.get("type") == "cre_policy_violation":
|
|
43
|
+
raise CREBlockedError(
|
|
44
|
+
blocked_reason=err.get("message", "policy violation"),
|
|
45
|
+
violations=err.get("violations", []),
|
|
46
|
+
raw_body=body,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _wrap_exception(exc: Exception, cre_url: str) -> None:
|
|
51
|
+
"""Convert underlying SDK exceptions into CRE-specific ones where applicable."""
|
|
52
|
+
try:
|
|
53
|
+
import httpx
|
|
54
|
+
if isinstance(exc, httpx.ConnectError):
|
|
55
|
+
raise CREUnavailableError(cre_url, cause=exc) from exc
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Anthropic SDK exception inspection
|
|
60
|
+
exc_type = type(exc).__name__
|
|
61
|
+
if exc_type in ("AuthenticationError",):
|
|
62
|
+
raise CREAuthError() from exc
|
|
63
|
+
|
|
64
|
+
if exc_type in ("BadRequestError", "APIStatusError"):
|
|
65
|
+
body = getattr(exc, "body", None)
|
|
66
|
+
_check_cre_block(body)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _StreamContextWrapper:
|
|
70
|
+
"""Wraps an Anthropic stream context manager to catch CRE blocks on entry."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, stream_cm: Any, cre_url: str) -> None:
|
|
73
|
+
self._cm = stream_cm
|
|
74
|
+
self._cre_url = cre_url
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> Any:
|
|
77
|
+
try:
|
|
78
|
+
return self._cm.__enter__()
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
_wrap_exception(exc, self._cre_url)
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *args: Any) -> Any:
|
|
84
|
+
return self._cm.__exit__(*args)
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, name: str) -> Any:
|
|
87
|
+
return getattr(self._cm, name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _AsyncStreamContextWrapper:
|
|
91
|
+
def __init__(self, stream_cm: Any, cre_url: str) -> None:
|
|
92
|
+
self._cm = stream_cm
|
|
93
|
+
self._cre_url = cre_url
|
|
94
|
+
|
|
95
|
+
async def __aenter__(self) -> Any:
|
|
96
|
+
try:
|
|
97
|
+
return await self._cm.__aenter__()
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
_wrap_exception(exc, self._cre_url)
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
async def __aexit__(self, *args: Any) -> Any:
|
|
103
|
+
return await self._cm.__aexit__(*args)
|
|
104
|
+
|
|
105
|
+
def __getattr__(self, name: str) -> Any:
|
|
106
|
+
return getattr(self._cm, name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _MessagesWrapper:
|
|
110
|
+
"""Wraps anthropic.resources.Messages to surface CRE-specific errors."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, messages: Any, cre_url: str, fallback: bool) -> None:
|
|
113
|
+
self._messages = messages
|
|
114
|
+
self._cre_url = cre_url
|
|
115
|
+
self._fallback = fallback
|
|
116
|
+
|
|
117
|
+
def create(self, **kwargs: Any) -> Any:
|
|
118
|
+
try:
|
|
119
|
+
return self._messages.create(**kwargs)
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
_wrap_exception(exc, self._cre_url)
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
def stream(self, **kwargs: Any) -> _StreamContextWrapper:
|
|
125
|
+
# stream() returns a context manager; errors from CRE surface on __enter__
|
|
126
|
+
return _StreamContextWrapper(self._messages.stream(**kwargs), self._cre_url)
|
|
127
|
+
|
|
128
|
+
def __getattr__(self, name: str) -> Any:
|
|
129
|
+
return getattr(self._messages, name)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _AsyncMessagesWrapper:
|
|
133
|
+
def __init__(self, messages: Any, cre_url: str, fallback: bool) -> None:
|
|
134
|
+
self._messages = messages
|
|
135
|
+
self._cre_url = cre_url
|
|
136
|
+
self._fallback = fallback
|
|
137
|
+
|
|
138
|
+
async def create(self, **kwargs: Any) -> Any:
|
|
139
|
+
try:
|
|
140
|
+
return await self._messages.create(**kwargs)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
_wrap_exception(exc, self._cre_url)
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
def stream(self, **kwargs: Any) -> _AsyncStreamContextWrapper:
|
|
146
|
+
return _AsyncStreamContextWrapper(self._messages.stream(**kwargs), self._cre_url)
|
|
147
|
+
|
|
148
|
+
def __getattr__(self, name: str) -> Any:
|
|
149
|
+
return getattr(self._messages, name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SafeAnthropic:
|
|
153
|
+
"""Drop-in replacement for ``anthropic.Anthropic`` with CRE enforcement.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
cre_key: Your ``sk-cre-xxx`` key. Falls back to
|
|
157
|
+
``CRE_KEY`` then ``ANTHROPIC_API_KEY`` env vars.
|
|
158
|
+
cre_url: CRE daemon URL. Falls back to ``CRE_URL`` env var,
|
|
159
|
+
then ``http://localhost:8080``.
|
|
160
|
+
fallback_on_unavailable: If True and CRE is unreachable, raises
|
|
161
|
+
``CREUnavailableError`` (default False = fail fast).
|
|
162
|
+
**kwargs: Passed through to ``anthropic.Anthropic()``.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
CREBlockedError: When CRE blocks the request (policy violation).
|
|
166
|
+
CREUnavailableError: When CRE cannot be reached (and fallback is off).
|
|
167
|
+
CREAuthError: When the CRE key is rejected.
|
|
168
|
+
ImportError: If ``anthropic`` package is not installed.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
cre_key: str | None = None,
|
|
174
|
+
cre_url: str | None = None,
|
|
175
|
+
fallback_on_unavailable: bool = False,
|
|
176
|
+
**kwargs: Any,
|
|
177
|
+
) -> None:
|
|
178
|
+
try:
|
|
179
|
+
import anthropic
|
|
180
|
+
except ImportError as e:
|
|
181
|
+
raise ImportError(
|
|
182
|
+
"anthropic package is required: pip install 'contextwall-sdk[anthropic]'"
|
|
183
|
+
) from e
|
|
184
|
+
|
|
185
|
+
self._cre_url = (
|
|
186
|
+
cre_url or os.environ.get("CRE_URL") or "http://localhost:8080"
|
|
187
|
+
).rstrip("/")
|
|
188
|
+
self._fallback = fallback_on_unavailable
|
|
189
|
+
|
|
190
|
+
key = (
|
|
191
|
+
cre_key
|
|
192
|
+
or os.environ.get("CRE_KEY")
|
|
193
|
+
or os.environ.get("ANTHROPIC_API_KEY")
|
|
194
|
+
or ""
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
self._client = anthropic.Anthropic(
|
|
198
|
+
api_key=key,
|
|
199
|
+
base_url=f"{self._cre_url}/proxy/anthropic",
|
|
200
|
+
**kwargs,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def messages(self) -> _MessagesWrapper:
|
|
205
|
+
return _MessagesWrapper(self._client.messages, self._cre_url, self._fallback)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def beta(self) -> Any:
|
|
209
|
+
return self._client.beta
|
|
210
|
+
|
|
211
|
+
def __getattr__(self, name: str) -> Any:
|
|
212
|
+
return getattr(self._client, name)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AsyncSafeAnthropic:
|
|
216
|
+
"""Async version of SafeAnthropic. Drop-in for ``anthropic.AsyncAnthropic``.
|
|
217
|
+
|
|
218
|
+
Example::
|
|
219
|
+
|
|
220
|
+
client = AsyncSafeAnthropic()
|
|
221
|
+
response = await client.messages.create(
|
|
222
|
+
model="claude-opus-4-5",
|
|
223
|
+
max_tokens=1024,
|
|
224
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
225
|
+
)
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
cre_key: str | None = None,
|
|
231
|
+
cre_url: str | None = None,
|
|
232
|
+
fallback_on_unavailable: bool = False,
|
|
233
|
+
**kwargs: Any,
|
|
234
|
+
) -> None:
|
|
235
|
+
try:
|
|
236
|
+
import anthropic
|
|
237
|
+
except ImportError as e:
|
|
238
|
+
raise ImportError(
|
|
239
|
+
"anthropic package is required: pip install 'contextwall-sdk[anthropic]'"
|
|
240
|
+
) from e
|
|
241
|
+
|
|
242
|
+
self._cre_url = (
|
|
243
|
+
cre_url or os.environ.get("CRE_URL") or "http://localhost:8080"
|
|
244
|
+
).rstrip("/")
|
|
245
|
+
self._fallback = fallback_on_unavailable
|
|
246
|
+
|
|
247
|
+
key = (
|
|
248
|
+
cre_key
|
|
249
|
+
or os.environ.get("CRE_KEY")
|
|
250
|
+
or os.environ.get("ANTHROPIC_API_KEY")
|
|
251
|
+
or ""
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
self._client = anthropic.AsyncAnthropic(
|
|
255
|
+
api_key=key,
|
|
256
|
+
base_url=f"{self._cre_url}/proxy/anthropic",
|
|
257
|
+
**kwargs,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def messages(self) -> _AsyncMessagesWrapper:
|
|
262
|
+
return _AsyncMessagesWrapper(self._client.messages, self._cre_url, self._fallback)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def beta(self) -> Any:
|
|
266
|
+
return self._client.beta
|
|
267
|
+
|
|
268
|
+
def __getattr__(self, name: str) -> Any:
|
|
269
|
+
return getattr(self._client, name)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""SafeOpenAI - drop-in OpenAI client with CRE enforcement.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
# Before
|
|
6
|
+
import openai
|
|
7
|
+
client = openai.OpenAI(api_key="sk-...")
|
|
8
|
+
|
|
9
|
+
# After (one line change)
|
|
10
|
+
from contextwall_sdk import SafeOpenAI
|
|
11
|
+
client = SafeOpenAI(cre_key="sk-cre-...", cre_url="http://localhost:8080")
|
|
12
|
+
|
|
13
|
+
# Same API
|
|
14
|
+
response = client.chat.completions.create(
|
|
15
|
+
model="gpt-4o",
|
|
16
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Environment variables::
|
|
20
|
+
|
|
21
|
+
CRE_KEY=sk-cre-...
|
|
22
|
+
CRE_URL=http://localhost:8080
|
|
23
|
+
|
|
24
|
+
from contextwall_sdk import SafeOpenAI
|
|
25
|
+
client = SafeOpenAI()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .exceptions import CREBlockedError, CREUnavailableError, CREAuthError
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_cre_block(body: Any) -> None:
|
|
37
|
+
if not isinstance(body, dict):
|
|
38
|
+
return
|
|
39
|
+
err = body.get("error", {})
|
|
40
|
+
if isinstance(err, dict) and err.get("type") == "cre_policy_violation":
|
|
41
|
+
raise CREBlockedError(
|
|
42
|
+
blocked_reason=err.get("message", "policy violation"),
|
|
43
|
+
violations=err.get("violations", []),
|
|
44
|
+
raw_body=body,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _wrap_exception(exc: Exception, cre_url: str) -> None:
|
|
49
|
+
try:
|
|
50
|
+
import httpx
|
|
51
|
+
if isinstance(exc, httpx.ConnectError):
|
|
52
|
+
raise CREUnavailableError(cre_url, cause=exc) from exc
|
|
53
|
+
except ImportError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
exc_type = type(exc).__name__
|
|
57
|
+
if exc_type == "AuthenticationError":
|
|
58
|
+
raise CREAuthError() from exc
|
|
59
|
+
|
|
60
|
+
if exc_type in ("BadRequestError", "APIStatusError", "APIError"):
|
|
61
|
+
# OpenAI SDK stores the body differently
|
|
62
|
+
body = getattr(exc, "body", None) or getattr(exc, "response", {})
|
|
63
|
+
if hasattr(body, "json"):
|
|
64
|
+
try:
|
|
65
|
+
body = body.json()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
_check_cre_block(body)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _StreamContextWrapper:
|
|
72
|
+
"""Wraps an OpenAI stream context manager to catch CRE blocks on entry."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, stream_cm: Any, cre_url: str) -> None:
|
|
75
|
+
self._cm = stream_cm
|
|
76
|
+
self._cre_url = cre_url
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> Any:
|
|
79
|
+
try:
|
|
80
|
+
return self._cm.__enter__()
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
_wrap_exception(exc, self._cre_url)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *args: Any) -> Any:
|
|
86
|
+
return self._cm.__exit__(*args)
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name: str) -> Any:
|
|
89
|
+
return getattr(self._cm, name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _AsyncStreamContextWrapper:
|
|
93
|
+
def __init__(self, stream_cm: Any, cre_url: str) -> None:
|
|
94
|
+
self._cm = stream_cm
|
|
95
|
+
self._cre_url = cre_url
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> Any:
|
|
98
|
+
try:
|
|
99
|
+
return await self._cm.__aenter__()
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
_wrap_exception(exc, self._cre_url)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
async def __aexit__(self, *args: Any) -> Any:
|
|
105
|
+
return await self._cm.__aexit__(*args)
|
|
106
|
+
|
|
107
|
+
def __getattr__(self, name: str) -> Any:
|
|
108
|
+
return getattr(self._cm, name)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _CompletionsWrapper:
|
|
112
|
+
def __init__(self, completions: Any, cre_url: str) -> None:
|
|
113
|
+
self._completions = completions
|
|
114
|
+
self._cre_url = cre_url
|
|
115
|
+
|
|
116
|
+
def create(self, **kwargs: Any) -> Any:
|
|
117
|
+
try:
|
|
118
|
+
return self._completions.create(**kwargs)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
_wrap_exception(exc, self._cre_url)
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
def stream(self, **kwargs: Any) -> _StreamContextWrapper:
|
|
124
|
+
return _StreamContextWrapper(self._completions.stream(**kwargs), self._cre_url)
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, name: str) -> Any:
|
|
127
|
+
return getattr(self._completions, name)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _AsyncCompletionsWrapper:
|
|
131
|
+
def __init__(self, completions: Any, cre_url: str) -> None:
|
|
132
|
+
self._completions = completions
|
|
133
|
+
self._cre_url = cre_url
|
|
134
|
+
|
|
135
|
+
async def create(self, **kwargs: Any) -> Any:
|
|
136
|
+
try:
|
|
137
|
+
return await self._completions.create(**kwargs)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
_wrap_exception(exc, self._cre_url)
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
def stream(self, **kwargs: Any) -> _AsyncStreamContextWrapper:
|
|
143
|
+
return _AsyncStreamContextWrapper(self._completions.stream(**kwargs), self._cre_url)
|
|
144
|
+
|
|
145
|
+
def __getattr__(self, name: str) -> Any:
|
|
146
|
+
return getattr(self._completions, name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _ChatWrapper:
|
|
150
|
+
def __init__(self, chat: Any, cre_url: str) -> None:
|
|
151
|
+
self._chat = chat
|
|
152
|
+
self._cre_url = cre_url
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def completions(self) -> _CompletionsWrapper:
|
|
156
|
+
return _CompletionsWrapper(self._chat.completions, self._cre_url)
|
|
157
|
+
|
|
158
|
+
def __getattr__(self, name: str) -> Any:
|
|
159
|
+
return getattr(self._chat, name)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _AsyncChatWrapper:
|
|
163
|
+
def __init__(self, chat: Any, cre_url: str) -> None:
|
|
164
|
+
self._chat = chat
|
|
165
|
+
self._cre_url = cre_url
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def completions(self) -> _AsyncCompletionsWrapper:
|
|
169
|
+
return _AsyncCompletionsWrapper(self._chat.completions, self._cre_url)
|
|
170
|
+
|
|
171
|
+
def __getattr__(self, name: str) -> Any:
|
|
172
|
+
return getattr(self._chat, name)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SafeOpenAI:
|
|
176
|
+
"""Drop-in replacement for ``openai.OpenAI`` with CRE enforcement.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
cre_key: Your ``sk-cre-xxx`` key. Falls back to
|
|
180
|
+
``CRE_KEY`` then ``OPENAI_API_KEY`` env vars.
|
|
181
|
+
cre_url: CRE daemon URL. Falls back to ``CRE_URL`` env var,
|
|
182
|
+
then ``http://localhost:8080``.
|
|
183
|
+
**kwargs: Passed through to ``openai.OpenAI()``.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
CREBlockedError: When CRE blocks the request.
|
|
187
|
+
CREUnavailableError: When CRE cannot be reached.
|
|
188
|
+
CREAuthError: When the CRE key is rejected.
|
|
189
|
+
ImportError: If ``openai`` package is not installed.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
cre_key: str | None = None,
|
|
195
|
+
cre_url: str | None = None,
|
|
196
|
+
**kwargs: Any,
|
|
197
|
+
) -> None:
|
|
198
|
+
try:
|
|
199
|
+
import openai
|
|
200
|
+
except ImportError as e:
|
|
201
|
+
raise ImportError(
|
|
202
|
+
"openai package is required: pip install 'contextwall-sdk[openai]'"
|
|
203
|
+
) from e
|
|
204
|
+
|
|
205
|
+
self._cre_url = (
|
|
206
|
+
cre_url or os.environ.get("CRE_URL") or "http://localhost:8080"
|
|
207
|
+
).rstrip("/")
|
|
208
|
+
|
|
209
|
+
key = (
|
|
210
|
+
cre_key
|
|
211
|
+
or os.environ.get("CRE_KEY")
|
|
212
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
213
|
+
or ""
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
self._client = openai.OpenAI(
|
|
217
|
+
api_key=key,
|
|
218
|
+
base_url=f"{self._cre_url}/proxy/openai/v1",
|
|
219
|
+
**kwargs,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def chat(self) -> _ChatWrapper:
|
|
224
|
+
return _ChatWrapper(self._client.chat, self._cre_url)
|
|
225
|
+
|
|
226
|
+
def __getattr__(self, name: str) -> Any:
|
|
227
|
+
return getattr(self._client, name)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class AsyncSafeOpenAI:
|
|
231
|
+
"""Async version of SafeOpenAI. Drop-in for ``openai.AsyncOpenAI``."""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
cre_key: str | None = None,
|
|
236
|
+
cre_url: str | None = None,
|
|
237
|
+
**kwargs: Any,
|
|
238
|
+
) -> None:
|
|
239
|
+
try:
|
|
240
|
+
import openai
|
|
241
|
+
except ImportError as e:
|
|
242
|
+
raise ImportError(
|
|
243
|
+
"openai package is required: pip install 'contextwall-sdk[openai]'"
|
|
244
|
+
) from e
|
|
245
|
+
|
|
246
|
+
self._cre_url = (
|
|
247
|
+
cre_url or os.environ.get("CRE_URL") or "http://localhost:8080"
|
|
248
|
+
).rstrip("/")
|
|
249
|
+
|
|
250
|
+
key = (
|
|
251
|
+
cre_key
|
|
252
|
+
or os.environ.get("CRE_KEY")
|
|
253
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
254
|
+
or ""
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
self._client = openai.AsyncOpenAI(
|
|
258
|
+
api_key=key,
|
|
259
|
+
base_url=f"{self._cre_url}/proxy/openai/v1",
|
|
260
|
+
**kwargs,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def chat(self) -> _AsyncChatWrapper:
|
|
265
|
+
return _AsyncChatWrapper(self._client.chat, self._cre_url)
|
|
266
|
+
|
|
267
|
+
def __getattr__(self, name: str) -> Any:
|
|
268
|
+
return getattr(self._client, name)
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""ContextWallClient - admin HTTP client for the ContextWall daemon.
|
|
2
|
+
|
|
3
|
+
Used for provisioning proxy keys, registering sources, checking health,
|
|
4
|
+
and querying analytics/lint results.
|
|
5
|
+
|
|
6
|
+
Example::
|
|
7
|
+
|
|
8
|
+
from contextwall_sdk import CREClient
|
|
9
|
+
|
|
10
|
+
cre = CREClient(api_key="...", base_url="http://localhost:8080")
|
|
11
|
+
|
|
12
|
+
# Provision a key for an agent
|
|
13
|
+
result = cre.keys.create(
|
|
14
|
+
project_id="my-agent",
|
|
15
|
+
project_name="Production Agent",
|
|
16
|
+
upstream_key="sk-ant-...",
|
|
17
|
+
provider="anthropic",
|
|
18
|
+
)
|
|
19
|
+
print(result.key) # sk-cre-xxx - save this
|
|
20
|
+
|
|
21
|
+
# Register a web search source as untrusted
|
|
22
|
+
cre.sources.register(
|
|
23
|
+
id="brave-search",
|
|
24
|
+
type="web_search",
|
|
25
|
+
trust_tier="untrusted",
|
|
26
|
+
owner="research-team",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Trigger a lint audit
|
|
30
|
+
report = cre.lint.run(window_days=30)
|
|
31
|
+
print(report["summary"])
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from typing import Any, Literal
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Response models ────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ProxyKeyResult:
|
|
48
|
+
key: str
|
|
49
|
+
key_preview: str
|
|
50
|
+
project_id: str
|
|
51
|
+
project_name: str
|
|
52
|
+
provider: str
|
|
53
|
+
created_at: str
|
|
54
|
+
warning: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ProxyKey:
|
|
59
|
+
key_id: str
|
|
60
|
+
project_id: str
|
|
61
|
+
project_name: str
|
|
62
|
+
provider: str
|
|
63
|
+
scopes: list[str]
|
|
64
|
+
created_at: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class HealthStatus:
|
|
69
|
+
status: Literal["healthy", "degraded", "down"]
|
|
70
|
+
subsystems: dict[str, Any]
|
|
71
|
+
timestamp: str
|
|
72
|
+
version: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class AnalyticsSummary:
|
|
77
|
+
total_requests: int
|
|
78
|
+
blocked_artifacts: int
|
|
79
|
+
policy_violations: int
|
|
80
|
+
active_sessions: int
|
|
81
|
+
window_hours: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Source:
|
|
86
|
+
id: str
|
|
87
|
+
type: str
|
|
88
|
+
trust_tier: str
|
|
89
|
+
owner: str
|
|
90
|
+
region: str
|
|
91
|
+
data_classification: str
|
|
92
|
+
registered_at: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Keys sub-client ────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
class _KeysClient:
|
|
98
|
+
def __init__(self, http: httpx.Client) -> None:
|
|
99
|
+
self._http = http
|
|
100
|
+
|
|
101
|
+
def create(
|
|
102
|
+
self,
|
|
103
|
+
project_id: str,
|
|
104
|
+
upstream_key: str,
|
|
105
|
+
project_name: str | None = None,
|
|
106
|
+
provider: Literal["anthropic", "openai", "any"] = "anthropic",
|
|
107
|
+
scopes: list[str] | None = None,
|
|
108
|
+
) -> ProxyKeyResult:
|
|
109
|
+
resp = self._http.post(
|
|
110
|
+
"/v1/keys",
|
|
111
|
+
json={
|
|
112
|
+
"project_id": project_id,
|
|
113
|
+
"project_name": project_name or project_id,
|
|
114
|
+
"upstream_key": upstream_key,
|
|
115
|
+
"provider": provider,
|
|
116
|
+
"scopes": scopes,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
d = resp.json()
|
|
121
|
+
return ProxyKeyResult(**{k: v for k, v in d.items()})
|
|
122
|
+
|
|
123
|
+
def list(self, project_id: str | None = None) -> list[ProxyKey]:
|
|
124
|
+
params = {"project_id": project_id} if project_id else {}
|
|
125
|
+
resp = self._http.get("/v1/keys", params=params)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
return [ProxyKey(**k) for k in resp.json().get("keys", [])]
|
|
128
|
+
|
|
129
|
+
def revoke(self, key_prefix: str) -> bool:
|
|
130
|
+
resp = self._http.delete(f"/v1/keys/{key_prefix}")
|
|
131
|
+
if resp.status_code == 404:
|
|
132
|
+
return False
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _AsyncKeysClient:
|
|
138
|
+
def __init__(self, http: httpx.AsyncClient) -> None:
|
|
139
|
+
self._http = http
|
|
140
|
+
|
|
141
|
+
async def create(
|
|
142
|
+
self,
|
|
143
|
+
project_id: str,
|
|
144
|
+
upstream_key: str,
|
|
145
|
+
project_name: str | None = None,
|
|
146
|
+
provider: Literal["anthropic", "openai", "any"] = "anthropic",
|
|
147
|
+
scopes: list[str] | None = None,
|
|
148
|
+
) -> ProxyKeyResult:
|
|
149
|
+
resp = await self._http.post(
|
|
150
|
+
"/v1/keys",
|
|
151
|
+
json={
|
|
152
|
+
"project_id": project_id,
|
|
153
|
+
"project_name": project_name or project_id,
|
|
154
|
+
"upstream_key": upstream_key,
|
|
155
|
+
"provider": provider,
|
|
156
|
+
"scopes": scopes,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
resp.raise_for_status()
|
|
160
|
+
d = resp.json()
|
|
161
|
+
return ProxyKeyResult(**{k: v for k, v in d.items()})
|
|
162
|
+
|
|
163
|
+
async def list(self, project_id: str | None = None) -> list[ProxyKey]:
|
|
164
|
+
params = {"project_id": project_id} if project_id else {}
|
|
165
|
+
resp = await self._http.get("/v1/keys", params=params)
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
return [ProxyKey(**k) for k in resp.json().get("keys", [])]
|
|
168
|
+
|
|
169
|
+
async def revoke(self, key_prefix: str) -> bool:
|
|
170
|
+
resp = await self._http.delete(f"/v1/keys/{key_prefix}")
|
|
171
|
+
if resp.status_code == 404:
|
|
172
|
+
return False
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── Sources sub-client ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
class _SourcesClient:
|
|
180
|
+
def __init__(self, http: httpx.Client) -> None:
|
|
181
|
+
self._http = http
|
|
182
|
+
|
|
183
|
+
def register(
|
|
184
|
+
self,
|
|
185
|
+
id: str,
|
|
186
|
+
type: str,
|
|
187
|
+
trust_tier: Literal["internal", "external", "untrusted", "regulated"],
|
|
188
|
+
owner: str = "",
|
|
189
|
+
region: str = "",
|
|
190
|
+
data_classification: str = "internal",
|
|
191
|
+
) -> Source:
|
|
192
|
+
resp = self._http.post(
|
|
193
|
+
"/v1/sources",
|
|
194
|
+
json={
|
|
195
|
+
"id": id,
|
|
196
|
+
"type": type,
|
|
197
|
+
"trust_tier": trust_tier,
|
|
198
|
+
"owner": owner,
|
|
199
|
+
"region": region,
|
|
200
|
+
"data_classification": data_classification,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
resp.raise_for_status()
|
|
204
|
+
return _parse_source(resp.json())
|
|
205
|
+
|
|
206
|
+
def list(self) -> list[Source]:
|
|
207
|
+
resp = self._http.get("/v1/sources")
|
|
208
|
+
resp.raise_for_status()
|
|
209
|
+
return [_parse_source(s) for s in resp.json().get("sources", [])]
|
|
210
|
+
|
|
211
|
+
def get(self, source_id: str) -> Source:
|
|
212
|
+
resp = self._http.get(f"/v1/sources/{source_id}")
|
|
213
|
+
resp.raise_for_status()
|
|
214
|
+
return _parse_source(resp.json())
|
|
215
|
+
|
|
216
|
+
def update_tier(
|
|
217
|
+
self,
|
|
218
|
+
source_id: str,
|
|
219
|
+
trust_tier: Literal["internal", "external", "untrusted", "regulated"],
|
|
220
|
+
) -> Source:
|
|
221
|
+
resp = self._http.patch(
|
|
222
|
+
f"/v1/sources/{source_id}",
|
|
223
|
+
json={"trust_tier": trust_tier},
|
|
224
|
+
)
|
|
225
|
+
resp.raise_for_status()
|
|
226
|
+
return _parse_source(resp.json())
|
|
227
|
+
|
|
228
|
+
def delete(self, source_id: str) -> bool:
|
|
229
|
+
resp = self._http.delete(f"/v1/sources/{source_id}")
|
|
230
|
+
if resp.status_code == 404:
|
|
231
|
+
return False
|
|
232
|
+
resp.raise_for_status()
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _AsyncSourcesClient:
|
|
237
|
+
def __init__(self, http: httpx.AsyncClient) -> None:
|
|
238
|
+
self._http = http
|
|
239
|
+
|
|
240
|
+
async def register(
|
|
241
|
+
self,
|
|
242
|
+
id: str,
|
|
243
|
+
type: str,
|
|
244
|
+
trust_tier: Literal["internal", "external", "untrusted", "regulated"],
|
|
245
|
+
owner: str = "",
|
|
246
|
+
region: str = "",
|
|
247
|
+
data_classification: str = "internal",
|
|
248
|
+
) -> Source:
|
|
249
|
+
resp = await self._http.post(
|
|
250
|
+
"/v1/sources",
|
|
251
|
+
json={
|
|
252
|
+
"id": id,
|
|
253
|
+
"type": type,
|
|
254
|
+
"trust_tier": trust_tier,
|
|
255
|
+
"owner": owner,
|
|
256
|
+
"region": region,
|
|
257
|
+
"data_classification": data_classification,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
resp.raise_for_status()
|
|
261
|
+
return _parse_source(resp.json())
|
|
262
|
+
|
|
263
|
+
async def list(self) -> list[Source]:
|
|
264
|
+
resp = await self._http.get("/v1/sources")
|
|
265
|
+
resp.raise_for_status()
|
|
266
|
+
return [_parse_source(s) for s in resp.json().get("sources", [])]
|
|
267
|
+
|
|
268
|
+
async def get(self, source_id: str) -> Source:
|
|
269
|
+
resp = await self._http.get(f"/v1/sources/{source_id}")
|
|
270
|
+
resp.raise_for_status()
|
|
271
|
+
return _parse_source(resp.json())
|
|
272
|
+
|
|
273
|
+
async def update_tier(
|
|
274
|
+
self,
|
|
275
|
+
source_id: str,
|
|
276
|
+
trust_tier: Literal["internal", "external", "untrusted", "regulated"],
|
|
277
|
+
) -> Source:
|
|
278
|
+
resp = await self._http.patch(
|
|
279
|
+
f"/v1/sources/{source_id}",
|
|
280
|
+
json={"trust_tier": trust_tier},
|
|
281
|
+
)
|
|
282
|
+
resp.raise_for_status()
|
|
283
|
+
return _parse_source(resp.json())
|
|
284
|
+
|
|
285
|
+
async def delete(self, source_id: str) -> bool:
|
|
286
|
+
resp = await self._http.delete(f"/v1/sources/{source_id}")
|
|
287
|
+
if resp.status_code == 404:
|
|
288
|
+
return False
|
|
289
|
+
resp.raise_for_status()
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_source(d: dict) -> Source:
|
|
294
|
+
return Source(
|
|
295
|
+
id=d.get("id", ""),
|
|
296
|
+
type=d.get("type", ""),
|
|
297
|
+
trust_tier=d.get("trust_tier", ""),
|
|
298
|
+
owner=d.get("owner", ""),
|
|
299
|
+
region=d.get("region", ""),
|
|
300
|
+
data_classification=d.get("data_classification", ""),
|
|
301
|
+
registered_at=d.get("registered_at", ""),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ── Lint sub-client ────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
class _LintClient:
|
|
308
|
+
def __init__(self, http: httpx.Client) -> None:
|
|
309
|
+
self._http = http
|
|
310
|
+
|
|
311
|
+
def latest(self) -> dict[str, Any]:
|
|
312
|
+
resp = self._http.get("/v1/lint/latest")
|
|
313
|
+
resp.raise_for_status()
|
|
314
|
+
return resp.json()
|
|
315
|
+
|
|
316
|
+
def run(self, window_days: int = 30) -> dict[str, Any]:
|
|
317
|
+
resp = self._http.post("/v1/lint/run", params={"window_days": window_days})
|
|
318
|
+
resp.raise_for_status()
|
|
319
|
+
return resp.json()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _AsyncLintClient:
|
|
323
|
+
def __init__(self, http: httpx.AsyncClient) -> None:
|
|
324
|
+
self._http = http
|
|
325
|
+
|
|
326
|
+
async def latest(self) -> dict[str, Any]:
|
|
327
|
+
resp = await self._http.get("/v1/lint/latest")
|
|
328
|
+
resp.raise_for_status()
|
|
329
|
+
return resp.json()
|
|
330
|
+
|
|
331
|
+
async def run(self, window_days: int = 30) -> dict[str, Any]:
|
|
332
|
+
resp = await self._http.post("/v1/lint/run", params={"window_days": window_days})
|
|
333
|
+
resp.raise_for_status()
|
|
334
|
+
return resp.json()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ── Main clients ───────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
class CREClient:
|
|
340
|
+
"""Synchronous admin client for the ContextWall daemon.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
api_key: ContextWall admin API key (set in ctxfw.yaml). Falls back to
|
|
344
|
+
``CRE_API_KEY`` env var, then ``CRE_API_TOKEN``.
|
|
345
|
+
base_url: ContextWall daemon URL. Falls back to ``CTXFW_URL`` env var,
|
|
346
|
+
then ``http://localhost:8080``.
|
|
347
|
+
timeout: Request timeout in seconds (default 30).
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(
|
|
351
|
+
self,
|
|
352
|
+
api_key: str | None = None,
|
|
353
|
+
base_url: str | None = None,
|
|
354
|
+
timeout: float = 30.0,
|
|
355
|
+
) -> None:
|
|
356
|
+
key = (
|
|
357
|
+
api_key
|
|
358
|
+
or os.environ.get("CRE_API_KEY")
|
|
359
|
+
or os.environ.get("CRE_API_TOKEN")
|
|
360
|
+
or ""
|
|
361
|
+
)
|
|
362
|
+
url = (
|
|
363
|
+
base_url
|
|
364
|
+
or os.environ.get("CRE_URL")
|
|
365
|
+
or "http://localhost:8080"
|
|
366
|
+
).rstrip("/")
|
|
367
|
+
|
|
368
|
+
self._http = httpx.Client(
|
|
369
|
+
base_url=url,
|
|
370
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
371
|
+
timeout=timeout,
|
|
372
|
+
)
|
|
373
|
+
self.keys = _KeysClient(self._http)
|
|
374
|
+
self.sources = _SourcesClient(self._http)
|
|
375
|
+
self.lint = _LintClient(self._http)
|
|
376
|
+
|
|
377
|
+
def health(self) -> HealthStatus:
|
|
378
|
+
resp = self._http.get("/health")
|
|
379
|
+
resp.raise_for_status()
|
|
380
|
+
d = resp.json()
|
|
381
|
+
return HealthStatus(
|
|
382
|
+
status=d["status"],
|
|
383
|
+
subsystems=d.get("subsystems", {}),
|
|
384
|
+
timestamp=d.get("timestamp", ""),
|
|
385
|
+
version=d.get("version"),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def analytics(self, window_hours: int = 24) -> AnalyticsSummary:
|
|
389
|
+
resp = self._http.get("/analytics/summary", params={"window_hours": window_hours})
|
|
390
|
+
resp.raise_for_status()
|
|
391
|
+
d = resp.json()
|
|
392
|
+
return AnalyticsSummary(
|
|
393
|
+
total_requests=d.get("total_requests", 0),
|
|
394
|
+
blocked_artifacts=d.get("blocked_artifacts", 0),
|
|
395
|
+
policy_violations=d.get("policy_violations", 0),
|
|
396
|
+
active_sessions=d.get("active_sessions", 0),
|
|
397
|
+
window_hours=window_hours,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def proxy_health(self) -> dict:
|
|
401
|
+
resp = self._http.get("/proxy/health")
|
|
402
|
+
resp.raise_for_status()
|
|
403
|
+
return resp.json()
|
|
404
|
+
|
|
405
|
+
def close(self) -> None:
|
|
406
|
+
self._http.close()
|
|
407
|
+
|
|
408
|
+
def __enter__(self) -> "CREClient":
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
def __exit__(self, *args: Any) -> None:
|
|
412
|
+
self.close()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class AsyncCREClient:
|
|
416
|
+
"""Async admin client for the ContextWall daemon. Same API as CREClient but awaitable."""
|
|
417
|
+
|
|
418
|
+
def __init__(
|
|
419
|
+
self,
|
|
420
|
+
api_key: str | None = None,
|
|
421
|
+
base_url: str | None = None,
|
|
422
|
+
timeout: float = 30.0,
|
|
423
|
+
) -> None:
|
|
424
|
+
key = (
|
|
425
|
+
api_key
|
|
426
|
+
or os.environ.get("CRE_API_KEY")
|
|
427
|
+
or os.environ.get("CRE_API_TOKEN")
|
|
428
|
+
or ""
|
|
429
|
+
)
|
|
430
|
+
url = (
|
|
431
|
+
base_url
|
|
432
|
+
or os.environ.get("CRE_URL")
|
|
433
|
+
or "http://localhost:8080"
|
|
434
|
+
).rstrip("/")
|
|
435
|
+
|
|
436
|
+
self._http = httpx.AsyncClient(
|
|
437
|
+
base_url=url,
|
|
438
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
439
|
+
timeout=timeout,
|
|
440
|
+
)
|
|
441
|
+
self.keys = _AsyncKeysClient(self._http)
|
|
442
|
+
self.sources = _AsyncSourcesClient(self._http)
|
|
443
|
+
self.lint = _AsyncLintClient(self._http)
|
|
444
|
+
|
|
445
|
+
async def health(self) -> HealthStatus:
|
|
446
|
+
resp = await self._http.get("/health")
|
|
447
|
+
resp.raise_for_status()
|
|
448
|
+
d = resp.json()
|
|
449
|
+
return HealthStatus(
|
|
450
|
+
status=d["status"],
|
|
451
|
+
subsystems=d.get("subsystems", {}),
|
|
452
|
+
timestamp=d.get("timestamp", ""),
|
|
453
|
+
version=d.get("version"),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
async def analytics(self, window_hours: int = 24) -> AnalyticsSummary:
|
|
457
|
+
resp = await self._http.get("/analytics/summary", params={"window_hours": window_hours})
|
|
458
|
+
resp.raise_for_status()
|
|
459
|
+
d = resp.json()
|
|
460
|
+
return AnalyticsSummary(
|
|
461
|
+
total_requests=d.get("total_requests", 0),
|
|
462
|
+
blocked_artifacts=d.get("blocked_artifacts", 0),
|
|
463
|
+
policy_violations=d.get("policy_violations", 0),
|
|
464
|
+
active_sessions=d.get("active_sessions", 0),
|
|
465
|
+
window_hours=window_hours,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
async def close(self) -> None:
|
|
469
|
+
await self._http.aclose()
|
|
470
|
+
|
|
471
|
+
async def __aenter__(self) -> "AsyncCREClient":
|
|
472
|
+
return self
|
|
473
|
+
|
|
474
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
475
|
+
await self.close()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""ContextWall SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CREError(Exception):
|
|
7
|
+
"""Base class for all ContextWall SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CREBlockedError(CREError):
|
|
11
|
+
"""Raised when ContextWall blocks a request due to a policy violation.
|
|
12
|
+
|
|
13
|
+
This replaces the generic ``BadRequestError`` the underlying SDK would raise,
|
|
14
|
+
giving you structured access to what was detected and why.
|
|
15
|
+
|
|
16
|
+
Example::
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
client.messages.create(...)
|
|
20
|
+
except CREBlockedError as e:
|
|
21
|
+
print(e.violations) # ["prompt_injection"]
|
|
22
|
+
print(e.blocked_reason) # "prompt_injection detected in message content"
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
blocked_reason: str,
|
|
28
|
+
violations: list[str],
|
|
29
|
+
raw_body: dict | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.blocked_reason = blocked_reason
|
|
32
|
+
self.violations = violations
|
|
33
|
+
self.raw_body = raw_body or {}
|
|
34
|
+
super().__init__(f"ContextWall blocked request: {blocked_reason}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CREUnavailableError(CREError):
|
|
38
|
+
"""Raised when the ContextWall daemon cannot be reached and fallback is disabled."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, url: str, cause: Exception | None = None) -> None:
|
|
41
|
+
self.url = url
|
|
42
|
+
self.cause = cause
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"ContextWall daemon unreachable at {url}. "
|
|
45
|
+
"Set fallback_on_unavailable=True to fall through to the real API."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CREAuthError(CREError):
|
|
50
|
+
"""Raised when the ContextWall key is invalid or revoked."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str = "Invalid or revoked ContextWall key") -> None:
|
|
53
|
+
super().__init__(message)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contextwall-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: ContextWall SDK - drop-in wrapper for Anthropic and OpenAI with context firewall enforcement
|
|
5
|
+
Author-email: Context Firewall <sumesh.punakkal@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: ai,anthropic,context-firewall,governance,openai,security
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Provides-Extra: all
|
|
12
|
+
Requires-Dist: anthropic>=0.25; extra == 'all'
|
|
13
|
+
Requires-Dist: openai>=1.0; extra == 'all'
|
|
14
|
+
Provides-Extra: anthropic
|
|
15
|
+
Requires-Dist: anthropic>=0.25; extra == 'anthropic'
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: anthropic>=0.25; extra == 'dev'
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
19
|
+
Requires-Dist: openai>=1.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# contextwall-sdk
|
|
27
|
+
|
|
28
|
+
Drop-in wrapper for Anthropic and OpenAI that routes calls through a local [ContextWall](https://contextwall.io) daemon, enforcing your context firewall policy with no other code changes.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install 'contextwall-sdk[anthropic]'
|
|
34
|
+
pip install 'contextwall-sdk[openai]'
|
|
35
|
+
pip install 'contextwall-sdk[all]'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from contextwall_sdk import SafeAnthropic
|
|
42
|
+
|
|
43
|
+
client = SafeAnthropic(api_key="sk-ant-...", ctxfw_url="http://localhost:8080")
|
|
44
|
+
# use exactly like the standard Anthropic client
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from contextwall_sdk import SafeOpenAI
|
|
49
|
+
|
|
50
|
+
client = SafeOpenAI(api_key="sk-...", ctxfw_url="http://localhost:8080")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Blocked requests raise `contextwall_sdk.ContextWallBlockedError` with the policy violation detail.
|
|
54
|
+
|
|
55
|
+
## Daemon setup
|
|
56
|
+
|
|
57
|
+
The SDK requires a running ContextWall daemon. See the [quickstart](https://contextwall.io/quickstart) or the [GitHub repo](https://github.com/bytewise-ca/context-wall).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
contextwall_sdk/__init__.py,sha256=RkO_uJB9K3vg6CHqo8weF0lf9ip_44M3u7hCTJzT6UY,1570
|
|
2
|
+
contextwall_sdk/_anthropic.py,sha256=aqGbxE-5nJE60b6dnwHrrtylK-L68ohw8ca2VzPH2fU,8347
|
|
3
|
+
contextwall_sdk/_openai.py,sha256=vcZYAmYKTkiCWaKzSpedXr-y3v9-75rDbTL61OYm1Hs,7818
|
|
4
|
+
contextwall_sdk/client.py,sha256=5zzJI4F01payxQ1-SA17sBg9-dImKo0TGJlSglVpo8g,14738
|
|
5
|
+
contextwall_sdk/exceptions.py,sha256=1wxFI2X3TztGp-D141h_MrkC-hPqX0ru24ugAuhe7-8,1653
|
|
6
|
+
contextwall_sdk-0.1.1.dist-info/METADATA,sha256=ZeGYK71g7y3ioDsjH3e_d2prxO4boXiyswLodkhmX2o,1893
|
|
7
|
+
contextwall_sdk-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
contextwall_sdk-0.1.1.dist-info/RECORD,,
|