revoc-sdk 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
revoc_sdk/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """revoc_sdk — zero-code-change governance SDK for REVOC.
2
+
3
+ from revoc_sdk import RevocOpenAI
4
+ client = RevocOpenAI(api_key="sk-...")
5
+
6
+ See https://revoc.io/docs for full documentation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from revoc_sdk.client import RevocAsyncOpenAI, RevocOpenAI
12
+ from revoc_sdk.exceptions import (
13
+ RevocDepthExceededError,
14
+ RevocGovernanceError,
15
+ RevocKillSwitchError,
16
+ RevocRateLimitError,
17
+ )
18
+ from revoc_sdk.governance import GovernanceClient
19
+ from revoc_sdk.mcp import RevocMcpClient, RevocMcpError, governed_tool_call
20
+ from revoc_sdk.patch import patch, unpatch
21
+
22
+ __version__ = "1.0.0"
23
+
24
+ __all__ = [
25
+ "GovernanceClient",
26
+ "RevocAsyncOpenAI",
27
+ "RevocDepthExceededError",
28
+ "RevocGovernanceError",
29
+ "RevocKillSwitchError",
30
+ "RevocMcpClient",
31
+ "RevocMcpError",
32
+ "RevocOpenAI",
33
+ "RevocRateLimitError",
34
+ "__version__",
35
+ "governed_tool_call",
36
+ "patch",
37
+ "unpatch",
38
+ ]
revoc_sdk/client.py ADDED
@@ -0,0 +1,194 @@
1
+ """revoc_sdk.client — drop-in OpenAI client subclasses routed through REVOC.
2
+
3
+ Usage (zero-code-change beyond the constructor):
4
+
5
+ from revoc_sdk import RevocOpenAI
6
+ client = RevocOpenAI(api_key="sk-...") # reads REVOC_BASE_URL
7
+ client = RevocOpenAI(api_key="sk-...", revoc_base_url="http://revoc:8080")
8
+
9
+ Both subclasses transparently translate REVOC governance error responses
10
+ (`RevocErrorResponse`, see SPEC.md Section 7.5) into typed Python
11
+ exceptions so callers can branch on governance outcomes instead of parsing
12
+ raw HTTP error bodies.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ import httpx
21
+ from openai import AsyncOpenAI, OpenAI
22
+
23
+ from revoc_sdk.exceptions import (
24
+ RevocDepthExceededError,
25
+ RevocGovernanceError,
26
+ RevocKillSwitchError,
27
+ RevocRateLimitError,
28
+ )
29
+ from revoc_sdk.governance import GovernanceClient
30
+
31
+
32
+ def _response_hook(response: httpx.Response) -> None:
33
+ """httpx event hook: parse REVOC error bodies and raise typed exceptions.
34
+
35
+ Only fires on 4xx/5xx responses, and only acts when the body matches the
36
+ `RevocErrorResponse` shape (`{"error": {"code": "REVOC_...", ...}}`).
37
+ Upstream provider errors are passed through untouched (Rule 8: forward
38
+ verbatim, never wrap).
39
+ """
40
+ if response.status_code < 400:
41
+ return
42
+ try:
43
+ response.read()
44
+ payload = response.json()
45
+ except Exception: # noqa: BLE001 — malformed/non-JSON bodies pass through
46
+ return
47
+ if not isinstance(payload, dict):
48
+ return
49
+ error = payload.get("error")
50
+ if not isinstance(error, dict):
51
+ return
52
+ code = error.get("code", "")
53
+ if not isinstance(code, str) or not code.startswith("REVOC_"):
54
+ return
55
+ if code == "REVOC_RATE_LIMIT_EXCEEDED":
56
+ retry_after = 0
57
+ try:
58
+ retry_after = int(response.headers.get("retry-after", "0"))
59
+ except ValueError:
60
+ retry_after = 0
61
+ raise RevocRateLimitError(retry_after_secs=retry_after)
62
+ if code == "REVOC_KILL_SWITCH_ACTIVE":
63
+ raise RevocKillSwitchError(
64
+ scope="global", dashboard_url=error.get("dashboard_url", "")
65
+ )
66
+ if code == "REVOC_GOVERNANCE_DEPTH_EXCEEDED":
67
+ depth = 10
68
+ message = error.get("message", "")
69
+ try:
70
+ fraction = message.split("(", 1)[1].split(")", 1)[0]
71
+ depth = int(fraction.split("/", 1)[0])
72
+ except (IndexError, ValueError):
73
+ pass
74
+ raise RevocDepthExceededError(depth=depth, max_depth=10)
75
+ if code == "REVOC_GOVERNANCE_BLOCK":
76
+ raise RevocGovernanceError(
77
+ reason=error.get("message", ""),
78
+ decision="block",
79
+ blocked_by=error.get("blocked_by"),
80
+ rules=error.get("rules") or [],
81
+ trace_id=error.get("trace_id", ""),
82
+ dashboard_url=error.get("dashboard_url", ""),
83
+ documentation_url=error.get("documentation_url"),
84
+ )
85
+
86
+
87
+ def _resolve_base_url(revoc_base_url: str | None, kwargs: dict[str, Any]) -> str | None:
88
+ """Resolution order: explicit kwarg > REVOC_BASE_URL env > caller-supplied base_url."""
89
+ if revoc_base_url:
90
+ return revoc_base_url
91
+ env_url = os.environ.get("REVOC_BASE_URL")
92
+ if env_url:
93
+ return env_url
94
+ existing = kwargs.pop("base_url", None)
95
+ return existing if isinstance(existing, str) or existing is None else None
96
+
97
+
98
+ class RevocOpenAI(OpenAI):
99
+ """Synchronous OpenAI client transparently governed by REVOC.
100
+
101
+ Drop-in replacement for ``openai.OpenAI``. All requests are routed
102
+ through the REVOC proxy (`base_url`), and governance error responses are
103
+ raised as typed exceptions (`RevocGovernanceError`, `RevocKillSwitchError`,
104
+ `RevocDepthExceededError`, `RevocRateLimitError`).
105
+ """
106
+
107
+ def __init__(self, *, revoc_base_url: str | None = None, **kwargs: Any) -> None:
108
+ base_url = _resolve_base_url(revoc_base_url, kwargs)
109
+ http_client = kwargs.pop("http_client", None)
110
+ if http_client is None:
111
+ http_client = httpx.Client(event_hooks={"response": [_response_hook]})
112
+ super().__init__(base_url=base_url, http_client=http_client, **kwargs)
113
+ self._revoc_governance: GovernanceClient | None = None
114
+
115
+ @property
116
+ def governance(self) -> GovernanceClient:
117
+ """Lazily-constructed client for the REVOC dashboard / admin-api."""
118
+ if self._revoc_governance is None:
119
+ base = str(self.base_url) if self.base_url else ""
120
+ self._revoc_governance = GovernanceClient(base_url=base)
121
+ return self._revoc_governance
122
+
123
+
124
+ class RevocAsyncOpenAI(AsyncOpenAI):
125
+ """Asynchronous OpenAI client transparently governed by REVOC.
126
+
127
+ Drop-in replacement for ``openai.AsyncOpenAI`` with the same governance
128
+ exception translation as `RevocOpenAI`.
129
+ """
130
+
131
+ def __init__(self, *, revoc_base_url: str | None = None, **kwargs: Any) -> None:
132
+ base_url = _resolve_base_url(revoc_base_url, kwargs)
133
+ http_client = kwargs.pop("http_client", None)
134
+ if http_client is None:
135
+ http_client = httpx.AsyncClient(event_hooks={"response": [_async_response_hook]})
136
+ super().__init__(base_url=base_url, http_client=http_client, **kwargs)
137
+ self._revoc_governance: GovernanceClient | None = None
138
+
139
+ @property
140
+ def governance(self) -> GovernanceClient:
141
+ """Lazily-constructed client for the REVOC dashboard / admin-api."""
142
+ if self._revoc_governance is None:
143
+ base = str(self.base_url) if self.base_url else ""
144
+ self._revoc_governance = GovernanceClient(base_url=base)
145
+ return self._revoc_governance
146
+
147
+
148
+ async def _async_response_hook(response: httpx.Response) -> None:
149
+ """Async variant of `_response_hook` for `httpx.AsyncClient` event hooks."""
150
+ if response.status_code < 400:
151
+ return
152
+ try:
153
+ await response.aread()
154
+ payload = response.json()
155
+ except Exception: # noqa: BLE001
156
+ return
157
+ if not isinstance(payload, dict):
158
+ return
159
+ error = payload.get("error")
160
+ if not isinstance(error, dict):
161
+ return
162
+ code = error.get("code", "")
163
+ if not isinstance(code, str) or not code.startswith("REVOC_"):
164
+ return
165
+ if code == "REVOC_RATE_LIMIT_EXCEEDED":
166
+ retry_after = 0
167
+ try:
168
+ retry_after = int(response.headers.get("retry-after", "0"))
169
+ except ValueError:
170
+ retry_after = 0
171
+ raise RevocRateLimitError(retry_after_secs=retry_after)
172
+ if code == "REVOC_KILL_SWITCH_ACTIVE":
173
+ raise RevocKillSwitchError(
174
+ scope="global", dashboard_url=error.get("dashboard_url", "")
175
+ )
176
+ if code == "REVOC_GOVERNANCE_DEPTH_EXCEEDED":
177
+ depth = 10
178
+ message = error.get("message", "")
179
+ try:
180
+ fraction = message.split("(", 1)[1].split(")", 1)[0]
181
+ depth = int(fraction.split("/", 1)[0])
182
+ except (IndexError, ValueError):
183
+ pass
184
+ raise RevocDepthExceededError(depth=depth, max_depth=10)
185
+ if code == "REVOC_GOVERNANCE_BLOCK":
186
+ raise RevocGovernanceError(
187
+ reason=error.get("message", ""),
188
+ decision="block",
189
+ blocked_by=error.get("blocked_by"),
190
+ rules=error.get("rules") or [],
191
+ trace_id=error.get("trace_id", ""),
192
+ dashboard_url=error.get("dashboard_url", ""),
193
+ documentation_url=error.get("documentation_url"),
194
+ )
@@ -0,0 +1,91 @@
1
+ """revoc_sdk.exceptions — typed exceptions raised when REVOC governs a request.
2
+
3
+ These map 1:1 onto the `error.code` values produced by `RevocErrorResponse`
4
+ (see SPEC.md Section 7.5 / types/openai.rs). The REVOC client inspects the
5
+ JSON error body returned by the proxy and raises the matching exception type
6
+ so application code can branch on governance outcomes without parsing HTTP
7
+ responses by hand.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ class RevocGovernanceError(Exception):
14
+ """Raised when REVOC blocks a request due to a policy violation.
15
+
16
+ Corresponds to `error.code == "REVOC_GOVERNANCE_BLOCK"`.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ decision: str,
22
+ blocked_by: str | None,
23
+ rules: list[str],
24
+ trace_id: str,
25
+ dashboard_url: str,
26
+ documentation_url: str | None = None,
27
+ *,
28
+ reason: str | None = None,
29
+ ) -> None:
30
+ self.decision = decision
31
+ self.blocked_by = blocked_by
32
+ self.rules = rules
33
+ self.trace_id = trace_id
34
+ self.dashboard_url = dashboard_url
35
+ self.documentation_url = documentation_url
36
+ rule_list = ", ".join(rules) or blocked_by or "unknown"
37
+ self.reason = reason or f"Request blocked by rule(s): {rule_list}"
38
+ super().__init__(str(self))
39
+
40
+ def __str__(self) -> str:
41
+ return (
42
+ f"REVOC blocked [{self.blocked_by}]: {self.reason}\n"
43
+ f"Trace: {self.trace_id}\nDashboard: {self.dashboard_url}"
44
+ )
45
+
46
+
47
+ class RevocKillSwitchError(Exception):
48
+ """Raised when the emergency kill-switch is active.
49
+
50
+ Corresponds to `error.code == "REVOC_KILL_SWITCH_ACTIVE"`.
51
+ """
52
+
53
+ def __init__(self, *, scope: str, dashboard_url: str) -> None:
54
+ self.scope = scope
55
+ self.dashboard_url = dashboard_url
56
+ super().__init__(str(self))
57
+
58
+ def __str__(self) -> str:
59
+ return "REVOC emergency stop active. Contact your AI platform team."
60
+
61
+
62
+ class RevocDepthExceededError(Exception):
63
+ """Raised when governance depth reaches the configured maximum.
64
+
65
+ Corresponds to `error.code == "REVOC_GOVERNANCE_DEPTH_EXCEEDED"`.
66
+ """
67
+
68
+ def __init__(self, *, depth: int, max_depth: int = 10) -> None:
69
+ self.depth = depth
70
+ self.max_depth = max_depth
71
+ super().__init__(str(self))
72
+
73
+ def __str__(self) -> str:
74
+ return (
75
+ f"Governance depth {self.depth}/{self.max_depth} reached. "
76
+ "Possible infinite agent loop."
77
+ )
78
+
79
+
80
+ class RevocRateLimitError(Exception):
81
+ """Raised when the caller has exceeded their configured rate limit.
82
+
83
+ Corresponds to `error.code == "REVOC_RATE_LIMIT_EXCEEDED"`.
84
+ """
85
+
86
+ def __init__(self, *, retry_after_secs: int) -> None:
87
+ self.retry_after_secs = retry_after_secs
88
+ super().__init__(str(self))
89
+
90
+ def __str__(self) -> str:
91
+ return f"REVOC rate limit exceeded. Retry after {self.retry_after_secs} seconds."
@@ -0,0 +1,96 @@
1
+ """revoc_sdk.governance — async client for the REVOC dashboard / admin-api.
2
+
3
+ Exposed via `client.governance` on `RevocOpenAI` / `RevocAsyncOpenAI`. All
4
+ methods are thin wrappers over the admin-api REST surface (see
5
+ admin-api/app/routers/*) using httpx.AsyncClient under the hood.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+
16
+ class GovernanceClient:
17
+ """Async helper for querying audit data and controlling governance state.
18
+
19
+ Parameters
20
+ ----------
21
+ base_url:
22
+ Base URL of the REVOC dashboard / admin-api (e.g. ``http://revoc:3000``).
23
+ api_key:
24
+ Optional bearer token / API key used to authenticate against the
25
+ admin-api. Sent as ``Authorization: Bearer <api_key>`` when present.
26
+ """
27
+
28
+ def __init__(self, base_url: str, *, api_key: str | None = None) -> None:
29
+ self._base_url = base_url.rstrip("/")
30
+ self._api_key = api_key
31
+
32
+ def _headers(self) -> dict[str, str]:
33
+ headers: dict[str, str] = {}
34
+ if self._api_key:
35
+ headers["Authorization"] = f"Bearer {self._api_key}"
36
+ return headers
37
+
38
+ async def get_audit_events(self, **kwargs: Any) -> list[dict[str, Any]]:
39
+ """Fetch audit events matching the given query parameters.
40
+
41
+ Accepts the same query parameters as ``GET /api/v1/audit`` (e.g.
42
+ ``org_id``, ``decision``, ``limit``, ``offset``).
43
+ """
44
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
45
+ resp = await client.get("/api/v1/audit", params=kwargs, headers=self._headers())
46
+ resp.raise_for_status()
47
+ data: list[dict[str, Any]] = resp.json()
48
+ return data
49
+
50
+ async def get_audit_stats(self, start_time: datetime, end_time: datetime) -> dict[str, Any]:
51
+ """Fetch aggregate audit statistics for a time window."""
52
+ params = {"start_time": start_time.isoformat(), "end_time": end_time.isoformat()}
53
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
54
+ resp = await client.get("/api/v1/audit/stats", params=params, headers=self._headers())
55
+ resp.raise_for_status()
56
+ data: dict[str, Any] = resp.json()
57
+ return data
58
+
59
+ async def validate_policy(self, yaml_content: str) -> dict[str, Any]:
60
+ """Validate a policy bundle YAML manifest without activating it."""
61
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
62
+ resp = await client.post(
63
+ "/api/v1/policies/validate",
64
+ json={"yaml_content": yaml_content},
65
+ headers=self._headers(),
66
+ )
67
+ resp.raise_for_status()
68
+ data: dict[str, Any] = resp.json()
69
+ return data
70
+
71
+ async def activate_kill_switch(self, scope: str, reason: str) -> dict[str, Any]:
72
+ """Activate the emergency kill-switch for the given scope.
73
+
74
+ ``scope`` is one of ``"global"``, ``"org"``, or ``"caller"``.
75
+ """
76
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
77
+ resp = await client.post(
78
+ "/api/v1/emergency/kill-switch/activate",
79
+ json={"scope": scope, "reason": reason, "activated_by": "revoc-sdk"},
80
+ headers=self._headers(),
81
+ )
82
+ resp.raise_for_status()
83
+ data: dict[str, Any] = resp.json()
84
+ return data
85
+
86
+ async def deactivate_kill_switch(self, scope: str) -> dict[str, Any]:
87
+ """Deactivate the emergency kill-switch for the given scope."""
88
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
89
+ resp = await client.post(
90
+ "/api/v1/emergency/kill-switch/deactivate",
91
+ json={"scope": scope},
92
+ headers=self._headers(),
93
+ )
94
+ resp.raise_for_status()
95
+ data: dict[str, Any] = resp.json()
96
+ return data
revoc_sdk/mcp.py ADDED
@@ -0,0 +1,138 @@
1
+ """revoc_sdk.mcp — helpers for governed Model Context Protocol tool calls.
2
+
3
+ REVOC inspects MCP traffic bidirectionally (SPEC.md Section 11, MCP Handler
4
+ 12-step lifecycle). When a `tools/call` request is blocked, the proxy returns
5
+ a JSON-RPC error response shaped like `McpBlockResponse`
6
+ (`{"error": {"code": -32603, "data": {"code": "REVOC_GOVERNANCE_BLOCK", ...}}}`).
7
+
8
+ This module provides a small helper that posts a governed `tools/call`
9
+ request through REVOC and raises :class:`RevocMcpError` when the call was
10
+ blocked, so MCP client code can handle governance outcomes uniformly with
11
+ the LLM-side exceptions in :mod:`revoc_sdk.exceptions`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+
21
+ class RevocMcpError(Exception):
22
+ """Raised when REVOC blocks an MCP `tools/call` request.
23
+
24
+ Mirrors the JSON-RPC error payload (`McpBlockResponse` /
25
+ `McpBlockData`, see SPEC.md Section 7.4): the upstream MCP server never
26
+ receives a blocked request (Rule 9 in Section 23).
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ message: str,
33
+ code: str,
34
+ rules: list[str],
35
+ trace_id: str,
36
+ dashboard_url: str,
37
+ ) -> None:
38
+ self.message = message
39
+ self.code = code
40
+ self.rules = rules
41
+ self.trace_id = trace_id
42
+ self.dashboard_url = dashboard_url
43
+ super().__init__(str(self))
44
+
45
+ def __str__(self) -> str:
46
+ return (
47
+ f"REVOC blocked MCP tool call [{self.code}]: {self.message}\n"
48
+ f"Trace: {self.dashboard_url}"
49
+ )
50
+
51
+
52
+ class RevocMcpClient:
53
+ """Stateful client for governed MCP `tools/call` requests through REVOC.
54
+
55
+ Wraps :func:`governed_tool_call` with a persistent base URL, default
56
+ headers, and an auto-incrementing JSON-RPC request id, so callers can
57
+ issue repeated governed tool calls without repeating boilerplate.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ base_url: str,
63
+ *,
64
+ headers: dict[str, str] | None = None,
65
+ ) -> None:
66
+ self.base_url = base_url
67
+ self.headers = headers or {}
68
+ self._next_id = 1
69
+
70
+ def _request_id(self) -> str:
71
+ request_id = str(self._next_id)
72
+ self._next_id += 1
73
+ return request_id
74
+
75
+ async def call_tool(
76
+ self,
77
+ tool_name: str,
78
+ arguments: dict[str, Any],
79
+ *,
80
+ server_id: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Issue a governed `tools/call` request.
83
+
84
+ Raises :class:`RevocMcpError` if REVOC blocks the call before it
85
+ ever reaches the upstream MCP server (SPEC.md §23 Rule 9).
86
+ """
87
+ return await governed_tool_call(
88
+ self.base_url,
89
+ tool_name=tool_name,
90
+ arguments=arguments,
91
+ server_id=server_id,
92
+ request_id=self._request_id(),
93
+ headers=self.headers,
94
+ )
95
+
96
+
97
+ async def governed_tool_call(
98
+ base_url: str,
99
+ *,
100
+ tool_name: str,
101
+ arguments: dict[str, Any],
102
+ server_id: str | None = None,
103
+ request_id: str = "1",
104
+ headers: dict[str, str] | None = None,
105
+ ) -> dict[str, Any]:
106
+ """POST a `tools/call` JSON-RPC request through the REVOC MCP endpoint.
107
+
108
+ Returns the parsed JSON-RPC result on success. Raises
109
+ :class:`RevocMcpError` if REVOC blocked the call before it ever reached
110
+ the upstream MCP server.
111
+ """
112
+ payload: dict[str, Any] = {
113
+ "jsonrpc": "2.0",
114
+ "id": request_id,
115
+ "method": "tools/call",
116
+ "params": {"name": tool_name, "arguments": arguments},
117
+ }
118
+ if server_id is not None:
119
+ payload["params"]["server_id"] = server_id
120
+
121
+ async with httpx.AsyncClient(base_url=base_url) as client:
122
+ resp = await client.post("/mcp", json=payload, headers=headers or {})
123
+ resp.raise_for_status()
124
+ body: dict[str, Any] = resp.json()
125
+
126
+ error = body.get("error")
127
+ if isinstance(error, dict):
128
+ raw_data = error.get("data")
129
+ data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {}
130
+ if data.get("code") == "REVOC_GOVERNANCE_BLOCK":
131
+ raise RevocMcpError(
132
+ message=error.get("message", ""),
133
+ code=data.get("code", "REVOC_GOVERNANCE_BLOCK"),
134
+ rules=data.get("rules") or [],
135
+ trace_id=data.get("trace_id", ""),
136
+ dashboard_url=data.get("dashboard_url", ""),
137
+ )
138
+ return body
revoc_sdk/patch.py ADDED
@@ -0,0 +1,76 @@
1
+ """revoc_sdk.patch — zero-code-change activation for existing OpenAI clients.
2
+
3
+ Calling :func:`patch` once at process startup monkey-patches
4
+ ``openai.OpenAI.__init__`` and ``openai.AsyncOpenAI.__init__`` so that *any*
5
+ existing code that constructs an ``OpenAI()`` or ``AsyncOpenAI()`` client is
6
+ transparently routed through REVOC — provided the ``REVOC_BASE_URL``
7
+ environment variable is set. No source changes required (SPEC.md Section 2).
8
+
9
+ export REVOC_BASE_URL=http://revoc:8080
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any
16
+
17
+ import openai
18
+
19
+ _PATCHED_ATTR = "_revoc_patched"
20
+ _ORIGINAL_SYNC_INIT_ATTR = "_revoc_original_sync_init"
21
+ _ORIGINAL_ASYNC_INIT_ATTR = "_revoc_original_async_init"
22
+
23
+
24
+ def patch() -> None:
25
+ """Monkey-patch ``openai.OpenAI``/``openai.AsyncOpenAI`` to honor REVOC_BASE_URL.
26
+
27
+ Idempotent — calling this more than once is a no-op after the first call.
28
+ If ``REVOC_BASE_URL`` is not set at call time, this function does nothing
29
+ (existing client construction behaves exactly as before).
30
+ """
31
+ revoc_url = os.environ.get("REVOC_BASE_URL")
32
+ if not revoc_url:
33
+ return
34
+
35
+ if getattr(openai.OpenAI, _PATCHED_ATTR, False):
36
+ return
37
+
38
+ _original_sync_init = openai.OpenAI.__init__
39
+ _original_async_init = openai.AsyncOpenAI.__init__
40
+
41
+ def _patched_sync_init(self: openai.OpenAI, *args: Any, **kwargs: Any) -> None:
42
+ kwargs.setdefault("base_url", os.environ.get("REVOC_BASE_URL"))
43
+ _original_sync_init(self, *args, **kwargs)
44
+
45
+ def _patched_async_init(self: openai.AsyncOpenAI, *args: Any, **kwargs: Any) -> None:
46
+ kwargs.setdefault("base_url", os.environ.get("REVOC_BASE_URL"))
47
+ _original_async_init(self, *args, **kwargs)
48
+
49
+ setattr(openai.OpenAI, _ORIGINAL_SYNC_INIT_ATTR, _original_sync_init)
50
+ setattr(openai.AsyncOpenAI, _ORIGINAL_ASYNC_INIT_ATTR, _original_async_init)
51
+ openai.OpenAI.__init__ = _patched_sync_init # type: ignore[method-assign]
52
+ openai.AsyncOpenAI.__init__ = _patched_async_init # type: ignore[method-assign]
53
+ setattr(openai.OpenAI, _PATCHED_ATTR, True)
54
+ setattr(openai.AsyncOpenAI, _PATCHED_ATTR, True)
55
+
56
+
57
+ def unpatch() -> None:
58
+ """Reverse the effect of :func:`patch`.
59
+
60
+ Restores ``openai.OpenAI.__init__`` and ``openai.AsyncOpenAI.__init__`` to
61
+ their pre-patch implementations. Idempotent — calling this when no patch
62
+ is active (or more than once) is a no-op.
63
+ """
64
+ if not getattr(openai.OpenAI, _PATCHED_ATTR, False):
65
+ return
66
+
67
+ original_sync_init = getattr(openai.OpenAI, _ORIGINAL_SYNC_INIT_ATTR)
68
+ original_async_init = getattr(openai.AsyncOpenAI, _ORIGINAL_ASYNC_INIT_ATTR)
69
+
70
+ openai.OpenAI.__init__ = original_sync_init # type: ignore[method-assign]
71
+ openai.AsyncOpenAI.__init__ = original_async_init # type: ignore[method-assign]
72
+
73
+ delattr(openai.OpenAI, _PATCHED_ATTR)
74
+ delattr(openai.AsyncOpenAI, _PATCHED_ATTR)
75
+ delattr(openai.OpenAI, _ORIGINAL_SYNC_INIT_ATTR)
76
+ delattr(openai.AsyncOpenAI, _ORIGINAL_ASYNC_INIT_ATTR)
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: revoc-sdk
3
+ Version: 1.0.0
4
+ Summary: Zero-code-change governance SDK for REVOC — drop-in OpenAI client wrapper.
5
+ Project-URL: Homepage, https://revoc.io
6
+ Project-URL: Documentation, https://revoc.io
7
+ Project-URL: Repository, https://github.com/revoc-ai/revoc
8
+ Project-URL: Bug Tracker, https://github.com/revoc-ai/revoc/issues
9
+ Project-URL: Changelog, https://github.com/revoc-ai/revoc/blob/main/CHANGELOG.md
10
+ Author: REVOC
11
+ License: Apache-2.0
12
+ Keywords: ai,governance,llm,openai,pii,proxy,redaction,security
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: openai>=1.50
24
+ Requires-Dist: pydantic>=2.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.13; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.7; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # revoc-sdk
33
+
34
+ Zero-code-change governance SDK for [REVOC](https://revoc.io) — drop-in
35
+ subclasses of the official `openai` Python client that route traffic through
36
+ the REVOC proxy and translate governance decisions into typed exceptions.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install revoc-sdk
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from revoc_sdk import RevocOpenAI, RevocGovernanceError
48
+
49
+ client = RevocOpenAI(api_key="sk-...", revoc_base_url="http://revoc:8080")
50
+
51
+ try:
52
+ resp = client.chat.completions.create(
53
+ model="gpt-4o-mini",
54
+ messages=[{"role": "user", "content": "Hello"}],
55
+ )
56
+ except RevocGovernanceError as exc:
57
+ print(f"Blocked by {exc.blocked_by}: {exc.reason}")
58
+ print(f"See {exc.dashboard_url}")
59
+ ```
60
+
61
+ ## Zero-code-change activation
62
+
63
+ ```bash
64
+ export REVOC_BASE_URL=http://revoc:8080
65
+ ```
66
+
67
+ ```python
68
+ import revoc_sdk
69
+ revoc_sdk.patch() # patches openai.OpenAI / openai.AsyncOpenAI
70
+
71
+ from openai import OpenAI
72
+ client = OpenAI(api_key="sk-...") # transparently routed through REVOC
73
+ ```
74
+
75
+ ## Governance API
76
+
77
+ ```python
78
+ import asyncio
79
+ from datetime import datetime, timedelta, timezone
80
+
81
+ async def main():
82
+ stats = await client.governance.get_audit_stats(
83
+ start_time=datetime.now(timezone.utc) - timedelta(days=1),
84
+ end_time=datetime.now(timezone.utc),
85
+ )
86
+ print(stats)
87
+
88
+ asyncio.run(main())
89
+ ```
90
+
91
+ ## Exceptions
92
+
93
+ | Exception | Raised when |
94
+ |---|---|
95
+ | `RevocGovernanceError` | A policy rule blocked the request |
96
+ | `RevocKillSwitchError` | The emergency kill-switch is active |
97
+ | `RevocDepthExceededError` | Governance depth reached the maximum (10) |
98
+ | `RevocRateLimitError` | The caller exceeded their rate limit |
99
+ | `RevocMcpError` | An MCP `tools/call` was blocked before reaching the server |
100
+
101
+ ## License
102
+
103
+ Apache 2.0
@@ -0,0 +1,9 @@
1
+ revoc_sdk/__init__.py,sha256=blRq6jJ592ZLHHosGAbcUZ-2y-xI51x88FTgjyTfKZw,978
2
+ revoc_sdk/client.py,sha256=s5bEtFFRvEQJquhEzl8V3dLeYSSBNCxANbwYrDssGMQ,7456
3
+ revoc_sdk/exceptions.py,sha256=Ke9EU2CfWEgAJFsGUftgKpwb9sd9bkanXWF6QRlyHFE,2924
4
+ revoc_sdk/governance.py,sha256=gYmKnS3nP53s1jIhPVCQTliFnv4OvUSBT1itsfhixYE,3938
5
+ revoc_sdk/mcp.py,sha256=N4jMnLE0BbKAtUpGHjStFys7PYrDP9r_0_cK1HeErLw,4417
6
+ revoc_sdk/patch.py,sha256=gazD3av8-HBkAQC-_r9D9_VHBCDA5gIh6ZZHerPu6CI,3043
7
+ revoc_sdk-1.0.0.dist-info/METADATA,sha256=ELYgl4qxh5hlW_bbliXILC4h9pmwQuEcHs_wOa-vW2w,3016
8
+ revoc_sdk-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ revoc_sdk-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any