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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any