Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 17 additions & 35 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,52 +1,34 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "governs-ai-sdk"
version = "1.0.0"
description = "Python SDK for GovernsAI - AI governance and compliance platform"
version = "0.1.0-alpha.1"
description = "GovernsAI Python SDK"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "GovernsAI", email = "support@governs.ai"},
]
license = { text = "Elastic-2.0" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"License :: Other/Proprietary License",
"Operating System :: OS Independent",
]
dependencies = [
"requests>=2.25.0",
"pydantic>=1.8.0",
"typing-extensions>=3.10.0",
"aiohttp>=3.8.0",
"asyncio-throttle>=1.0.0",
"httpx>=0.24.0",
"pydantic>=2.0.0",
]

[project.optional-dependencies]
dev = [
"pytest>=6.0",
"pytest-asyncio>=0.18.0",
"black>=22.0",
"flake8>=4.0",
"mypy>=0.950",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-httpx>=0.21.0",
"black>=23.0.0",
"isort>=5.12.0",
"mypy>=1.0.0",
]

[tool.black]
line-length = 88
target-version = ['py38']

[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
4 changes: 4 additions & 0 deletions src/governs_ai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .client import GovernsAIClient, GovernsAIError, PrecheckError
from .types import PrecheckResult

__all__ = ["GovernsAIClient", "GovernsAIError", "PrecheckError", "PrecheckResult"]
182 changes: 182 additions & 0 deletions src/governs_ai/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import asyncio
import time
from typing import Any, Dict, List, Optional, Union

import httpx

from .types import PrecheckResult


class GovernsAIError(Exception):
"""Base error for GovernsAI SDK"""

def __init__(
self,
message: str,
status_code: Optional[int] = None,
response: Optional[Any] = None,
retryable: bool = False,
):
super().__init__(message)
self.status_code = status_code
self.response = response
self.retryable = retryable


class PrecheckError(GovernsAIError):
"""Error during precheck operation"""

pass


class GovernsAIClient:
"""
Main SDK client for GovernsAI.
"""

def __init__(
self,
api_key: str,
base_url: str = "https://api.governs.ai",
org_id: Optional[str] = None,
timeout: float = 30.0,
max_retries: int = 3,
):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.org_id = org_id
self.timeout = timeout
self.max_retries = max_retries
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"X-Governs-Key": self.api_key,
"Content-Type": "application/json",
"X-SDK-Language": "python",
}

def __repr__(self):
return f"<GovernsAIClient(base_url='{self.base_url}', org_id='{self.org_id}')>"

def _get_payload(
self, content: str, tool: str, org_id: Optional[str]
) -> Dict[str, Any]:
return {
"tool": tool,
"raw_text": content,
"org_id": org_id or self.org_id,
"scope": "net.external",
}

def _parse_response(
self, response: httpx.Response, latency_ms: float
) -> PrecheckResult:
if response.status_code >= 400:
try:
error_data = response.json()
message = error_data.get("error") or error_data.get("message")
except Exception:
message = None

if not message:
message = f"HTTP {response.status_code} {response.reason_phrase}"

retryable = response.status_code >= 500 or response.status_code == 429
raise PrecheckError(
message,
status_code=response.status_code,
response=response,
retryable=retryable,
)

data = response.json()
return PrecheckResult(
decision=data.get("decision", "deny"),
redacted_content=data.get("redacted_content")
or data.get("content", {}).get("raw_text"),
reasons=data.get("reasons", []),
latency_ms=latency_ms,
)

def precheck(
self,
content: str,
tool: str,
org_id: Optional[str] = None,
) -> PrecheckResult:
"""
Check a request for governance compliance.
"""
payload = self._get_payload(content, tool, org_id)
start_time = time.time()

last_error_msg = "Unknown error"
for attempt in range(self.max_retries + 1):
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.post(
f"{self.base_url}/api/v1/precheck",
json=payload,
headers=self.headers,
)

if response.status_code >= 500 or response.status_code == 429:
last_error_msg = (
f"HTTP {response.status_code} {response.reason_phrase}"
)
if attempt < self.max_retries:
time.sleep(2**attempt)
continue
else:
break

latency_ms = (time.time() - start_time) * 1000
return self._parse_response(response, latency_ms)
except (httpx.RequestError, httpx.TimeoutException) as e:
last_error_msg = str(e)
if attempt < self.max_retries:
time.sleep(2**attempt)
continue

raise PrecheckError(f"Max retries exceeded: {last_error_msg}")

async def async_precheck(
self,
content: str,
tool: str,
org_id: Optional[str] = None,
) -> PrecheckResult:
"""
Async version of precheck.
"""
payload = self._get_payload(content, tool, org_id)
start_time = time.time()

last_error_msg = "Unknown error"
for attempt in range(self.max_retries + 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/precheck",
json=payload,
headers=self.headers,
)

if response.status_code >= 500 or response.status_code == 429:
last_error_msg = (
f"HTTP {response.status_code} {response.reason_phrase}"
)
if attempt < self.max_retries:
await asyncio.sleep(2**attempt)
continue
else:
break

latency_ms = (time.time() - start_time) * 1000
return self._parse_response(response, latency_ms)
except (httpx.RequestError, httpx.TimeoutException) as e:
last_error_msg = str(e)
if attempt < self.max_retries:
await asyncio.sleep(2**attempt)
continue

raise PrecheckError(f"Max retries exceeded: {last_error_msg}")
10 changes: 10 additions & 0 deletions src/governs_ai/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


@dataclass
class PrecheckResult:
decision: str
redacted_content: Optional[str] = None
reasons: List[str] = field(default_factory=list)
latency_ms: float = 0.0
3 changes: 0 additions & 3 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +0,0 @@
"""
Tests for the GovernsAI Python SDK.
"""
28 changes: 28 additions & 0 deletions tests/integration_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import asyncio
import os

from governs_ai.client import GovernsAIClient


async def main():
api_key = os.getenv("GOVERNS_API_KEY", "test-key")
base_url = os.getenv("GOVERNS_BASE_URL", "http://localhost:8000")
org_id = os.getenv("GOVERNS_ORG_ID", "test-org")

client = GovernsAIClient(api_key=api_key, base_url=base_url, org_id=org_id)

print(f"Checking precheck against {base_url}...")
try:
result = await client.async_precheck(
content="Hello, is this safe?", tool="chat"
)
print(f"Decision: {result.decision}")
print(f"Redacted: {result.redacted_content}")
print(f"Reasons: {result.reasons}")
print(f"Latency: {result.latency_ms:.2f}ms")
except Exception as e:
print(f"Precheck failed: {e}")


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading