Metadata-Version: 2.4
Name: specora
Version: 0.5.0
Summary: Specora Platform SDK for Python - AI governance and policy enforcement
Project-URL: Homepage, https://docs.specora.ai/sdk/python
Project-URL: Documentation, https://docs.specora.ai/sdk/python
Project-URL: Repository, https://github.com/specora/sdk-python.git
Project-URL: Issues, https://github.com/specora/sdk-python/issues
Author-email: Specora <support@specora.ai>
License-Expression: MIT
Keywords: ai,compliance,governance,llm,policy,sdk,specora
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1.0,>=0.25.0
Requires-Dist: pydantic<3.0,>=2.0.0
Provides-Extra: dev
Requires-Dist: mypy>=1.0.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Requires-Dist: respx>=0.20.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Description-Content-Type: text/markdown

# specora

Specora Platform SDK for Python - AI governance and policy enforcement.

## Installation

```bash
pip install specora
# or
poetry add specora
# or
uv add specora
```

## Quick Start

If you already call OpenAI or Anthropic, governing those calls is a one-line
swap. Replace your provider client with the matching Specora drop-in. Every call
then goes through policy and budget enforcement, and each execution is recorded
for the audit trail. You keep the same call sites.

```python
from specora import GovernedOpenAI

client = GovernedOpenAI(
    specora_api_key="sk_...",      # from the dashboard: Settings -> SDK Keys
    openai_api_key="sk-...",       # your existing OpenAI key (or set OPENAI_API_KEY)
)

# Same call you already write — now governed.
response = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "Summarize this PR."}],
)
print(response.content)
```

If a policy blocks the call or the budget is exhausted, the call raises before it
ever reaches the model (fail-closed), so no ungoverned request gets through.
`GovernedAnthropic` works the same way with `client.messages.create(...)`, and
`GovernedClient("openai", ...)` / `GovernedClient("anthropic", ...)` is a factory
if you pick the provider at runtime.

With `key_management_mode="specora_managed"`, Specora holds the provider
credential and proxies the call, so you do not pass a provider key at all.

### Full control: the explicit flow

When you need to drive each governance step yourself, use `SpecoraClient`
directly. The drop-in clients above are a thin wrapper over exactly this.

```python
from specora import SpecoraClient

client = SpecoraClient(
    base_url="https://api.specora.ai",
    api_key="sk_...",
    vendor_id="vendor-uuid",
    tenant_id="tenant-uuid",
)

# Check policy before AI execution
policy = client.check_policy(
    subject={"type": "pr_run", "id": "run-123"},
)

if policy.decision == "block":
    print(f"Blocked by policy: {policy.severity}")
    exit(1)

# Execute your AI operation...

# Record the execution
result = client.record_execution({
    "provider": "openai",
    "model": "gpt-4",
    "cost_micros": 5000,  # $0.005
    "input_tokens": 1000,
    "output_tokens": 500,
    "success": True,
})
```

## Model A vs Model B routing

Most apps are **Model A** (org-scoped): the org is resolved from your API key, so
you omit `vendor_id` / `tenant_id`. This is the default for normal SaaS orgs and
matches `@specora/sdk`.

```python
# Model A — org-scoped (recommended for most integrations)
client = SpecoraClient(
    base_url="https://api.specora.ai",
    api_key="sk_...",
    key_management_mode="specora_managed",
)
```

**Model B** (vendor-reseller) is for nested vendor/tenant hierarchies. Supply both
`vendor_id` and `tenant_id`. Supplying exactly one raises `ValueError` — partial
configuration is almost always a bug.

```python
# Model B — vendor/tenant-scoped
client = SpecoraClient(
    base_url="https://api.specora.ai",
    api_key="sk_...",
    vendor_id="vendor-uuid",
    tenant_id="tenant-uuid",
)
```

## Server-side proxy (no provider keys in your app)

With `key_management_mode="specora_managed"`, Specora holds provider credentials in
its KMS and injects them server-side. Your app holds no provider API keys.

```python
# Chat-shaped call (provider/model routed + governed + journaled server-side)
resp = client.proxy_execute({
    "provider": "anthropic",
    "model": "claude-3-sonnet",
    "messages": [{"role": "user", "content": "Summarize this PR"}],
    "tools": [...],              # optional tool-calling
    "routing_hints": {"purpose": "summary", "agent_name": "pr-summarizer"},
})
print(resp.content, resp.tool_calls, resp.finish_reason)
```

For providers whose request/response shape does not fit the chat contract
(image, 3D, audio, video, segmentation), use the **raw proxy**. Report
`cost_micros` yourself — Specora cannot parse cost from arbitrary provider
bodies.

```python
raw = client.proxy_execute_raw({
    "provider": "fal",
    "model": "fal-ai/fashn/v1.5",
    "endpoint": "/fal-ai/fashn/v1.5",
    "body": {"prompt": "a red dress"},
    "cost_micros": 5000,
})
print(raw.status, raw.raw_response)

# Long-running media jobs: submit async, then poll a short request at a time.
job = client.proxy_execute_raw({
    "provider": "fal",
    "model": "fal-ai/trellis-2",
    "endpoint": "/fal-ai/trellis-2",
    "execution_mode": "async",
})
while True:
    state = client.get_raw_proxy_job(job.job_id)
    if state.status in ("completed", "failed"):
        break
```

`recording_failed` is surfaced as `resp.recording_failed_warning`, never raised:
the execution succeeded and cost was incurred, so retrying would double-bill
(INV-SDK-PROXY-001).

## Async Client

```python
from specora import AsyncSpecoraClient

async with AsyncSpecoraClient(
    base_url="https://api.specora.ai",
    api_key="sk_...",
    vendor_id="vendor-uuid",
    tenant_id="tenant-uuid",
) as client:
    policy = await client.check_policy(
        subject={"type": "pr_run", "id": "run-123"},
    )
```

## Features

- **Policy Evaluation**: Check governance policies before AI operations
- **Budget Management**: Track and enforce spending limits
- **Execution Recording**: Record AI executions for tracking
- **Assumptions Validation**: Validate change assumptions
- **Evidence Generation**: Generate audit evidence bundles
- **Risk Scoring**: Get real-time risk metrics
- **Webhook Verification**: HMAC-SHA256 signature verification
- **Async Support**: Full async/await support with `AsyncSpecoraClient`

## Purpose-Based Routing

Assign specific LLM providers/models to specific agents or task types. For
example, route `safety-rewriter` to a local Ollama instance while routing
`style-assistant` to Claude Sonnet.

```python
auth = client.pre_authorize(
    provider="anthropic",
    model="claude-sonnet-4-20250514",
    estimated_tokens=2000,
    estimated_cost_micros=500,
    routing_hints={
        "purpose": "safety-rewriter",
        "agent_name": "gtm-920-content-generator",
    },
)

# The server may override provider/model based on purpose routing policies
print(auth.selected_provider)  # e.g., "ollama"
print(auth.selected_model)     # e.g., "llama3:70b"
```

The org admin creates `PURPOSE_ROUTING` governance policies that map agents
and task types to target providers/models. When `pre_authorize()` includes
routing hints, the server applies matching purpose routing rules. If no
fallback is configured and the target model is unavailable, the request is
blocked (fail-closed).

## Configuration

```python
client = SpecoraClient(
    # Required
    base_url="https://api.specora.ai",
    api_key="sk_...",
    vendor_id="vendor-uuid",
    tenant_id="tenant-uuid",

    # Optional
    fail_open=False,  # Default: False (fail-closed)
    timeout=30.0,     # Request timeout in seconds
    retries=2,        # Retry attempts
)
```

## Fail-Closed Semantics

By default, the SDK operates in fail-closed mode. This means:

- Network errors result in blocked decisions
- Server errors result in blocked decisions
- Timeouts result in blocked decisions

This ensures that if the Specora API is unavailable, your AI operations are blocked rather than allowed without governance.

To change this behavior:

```python
client = SpecoraClient(
    # ...
    fail_open=True,  # Errors result in allowed decisions
)
```

## API Reference

### `check_policy(subject, inputs=None)`

Evaluate governance policy for an operation.

```python
result = client.check_policy(
    subject={"type": "pr_run", "id": "run-123"},
    inputs={"context": "value"},  # optional
)

# result.decision: "allow" | "block" | "warn"
# result.severity: "low" | "medium" | "high" | "critical"
# result.invariants: list of InvariantResult
# result.override: OverrideEligibility
```

### `validate_budget(dimension="cost")`

Check budget status for the tenant.

```python
budget = client.validate_budget()

# budget.budget_remaining: float (USD)
# budget.budget_total: float (USD)
# budget.usage_percent: float (0-100)
# budget.forecast_breach: bool
# budget.enforcement_state: "normal" | "throttled" | "blocked"
```

### `record_execution(request)`

Record an AI execution for budget tracking.

```python
result = client.record_execution({
    "provider": "openai",
    "model": "gpt-4",
    "cost_micros": 5000,  # 1/1,000,000 USD
    "input_tokens": 1000,
    "output_tokens": 500,
    "duration_ms": 1500,
    "success": True,
    "external_run_id": "your-id",  # optional
    "metadata": {"key": "value"},  # optional
})

# result.execution_id: str
# result.budget_remaining_micros: int
# result.usage_percent: float
# result.enforcement_state: str
```

### `validate_assumptions(request)`

Validate assumptions for a change.

```python
from specora import Assumption

result = client.validate_assumptions({
    "run_id": "run-123",
    "assumptions": [
        {"id": "a1", "description": "No breaking changes", "impact_level": "high"},
    ],
})

# result.severity: "low" | "medium" | "high" | "critical"
# result.verification_required: bool
# result.merge_eligible: bool
# result.violations: list of AssumptionsViolation
# result.recommendations: list of str
```

### `generate_evidence_bundle(request)`

Generate a signed evidence bundle.

```python
evidence = client.generate_evidence_bundle({
    "run_id": "run-123",
    "artifact_types": ["confidence_report", "trace"],
    "include_provenance": True,
})

# evidence.bundle_id: str
# evidence.bundle_hash: str (sha256:...)
# evidence.storage_path: str
```

### `get_risk_score()`

Get risk metrics for the tenant.

```python
risk = client.get_risk_score()

# risk.risk_percentile: float (0-100)
# risk.drift_risk: float
# risk.override_frequency_risk: float
# risk.anomaly_status: "normal" | "elevated" | "critical"
```

### `verify_proof(manifest_hash)` (PR-ENT-520-02)

Verify a proof by manifest hash.

```python
result = client.verify_proof("abc123def456...")

# result.verified: bool
# result.proof_found: bool
# result.org_match: bool
# result.manifest_hash: str
# result.proof_type: str | None
# result.created_at: datetime | None
# result.published_root_match: bool | None
# result.manifest_spec_id: str | None  # e.g., "proof-manifest"
# result.manifest_schema_version: str | None  # e.g., "1.0.0"
```

**Manifest Versioning (PR-ENT-520-02-PATCH-01):** The response includes `manifest_spec_id` and `manifest_schema_version` for external verification. Store these values alongside the hash to enable offline verification with the correct contract version.

### `compute_proof_hash(data)` (Offline Verification)

Compute a proof hash locally using canonical JSON serialization.

```python
from specora import compute_proof_hash

# Compute hash matching server-side canonical JSON
hash_result = compute_proof_hash({
    "id": "...",
    "org_id": "...",
    "root_type": "ledger_daily",
    "root_hash": "...",
    "leaf_count": 100,
    "period_start": "2024-01-01T00:00:00+00:00",
    "period_end": "2024-01-31T23:59:59+00:00",
    "created_at": "2024-02-01T00:00:00+00:00",
})

print(f"Hash: {hash_result}")  # SHA-256 hex
```

**Canonical JSON Rules:**
- Sorted keys (alphabetically)
- Compact separators (`","` and `":"`)
- ASCII-safe encoding (`ensure_ascii=True`)
- UTF-8 encoding for bytes

## Webhook Verification

Verify incoming webhooks from Specora:

```python
from flask import Flask, request, jsonify
from specora import verify_webhook_signature, parse_webhook_payload

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    is_valid = verify_webhook_signature(
        request.data,
        request.headers.get('X-Specora-Signature'),
        request.headers.get('X-Specora-Timestamp'),
        os.environ['WEBHOOK_SECRET'],
    )

    if not is_valid:
        return jsonify({'error': 'Invalid signature'}), 401

    payload = parse_webhook_payload(request.data)

    if payload.event_type == 'platform.budget_breach':
        # Handle budget breach
        pass
    elif payload.event_type == 'platform.invariant_failed':
        # Handle policy violation
        pass

    return jsonify({'received': True})
```

### Webhook Event Types

- `platform.invariant_failed` - Governance invariant violation
- `platform.budget_breach` - Budget limit exceeded
- `platform.provider_quarantined` - AI provider quarantined
- `platform.drift_detected` - Model or schema drift
- `platform.break_glass_used` - Emergency override activated
- `platform.sla_breach` - SLA threshold violated
- `platform.risk_score_changed` - Significant risk change

## Error Handling

The SDK throws typed exceptions for different failure scenarios:

```python
from specora import (
    SpecoraClient,
    FailClosedError,
    BudgetExceededError,
    AuthenticationError,
    ValidationError,
)

try:
    result = client.check_policy(...)
except FailClosedError as e:
    # Network/server error in fail-closed mode
    print(f"Defaulting to blocked: {e.decision}")
except BudgetExceededError as e:
    # Budget exceeded
    print(f"Budget exceeded: {e.usage_percent}%")
except AuthenticationError:
    # Invalid API key
    pass
except ValidationError as e:
    # Invalid request parameters
    print(f"Validation errors: {e.validation_errors}")
```

## Security

### HTTPS Required

The SDK enforces HTTPS for all non-local `base_url` values. Plaintext HTTP
would expose your API key (sent as a `Bearer` token on every request) to
network observers.

```python
# OK - production
SpecoraClient(base_url="https://api.specora.ai", ...)

# OK - local development
SpecoraClient(base_url="http://localhost:8765", ...)
SpecoraClient(base_url="http://127.0.0.1:8765", ...)

# RAISES ValueError - plaintext HTTP to remote host
SpecoraClient(base_url="http://api.specora.ai", ...)
```

### Supply Chain

- **Minimal runtime dependencies** — only `httpx` and `pydantic`.
- **No install-time scripts** — `pip install` executes no lifecycle hooks.
- **Fail-closed default** — network/server errors block operations, not allow them.

### Network Hardening

- **No redirects** — httpx is configured with `follow_redirects=False` to
  prevent the `Authorization` header from leaking to redirect targets.
- **Response size cap** — responses over 10 MB are rejected to prevent OOM
  from a malicious or compromised server.
- **Retry-After cap** — server-provided `Retry-After` values are capped at
  120 seconds to prevent indefinite blocking.

### Credential Handling

- API keys are only transmitted in the `Authorization` header over HTTPS.
- Keys are never logged or serialized to error messages.
- `repr(client)` returns `SpecoraClient('https://...')` — no API key exposed.
- Webhook verification uses constant-time comparison (`hmac.compare_digest`)
  to prevent timing attacks.
- Empty webhook secrets are rejected to prevent signature forgery.

### Governance Router Safety

- **Message size limits** — max 1024 messages and 10 MB total content per request.
- **Authorization expiry** — checked before LLM execution to prevent stale authorizations.
- **Conservative token estimation** — uses chars/3 (not chars/4) to prevent budget overspend.
- **Recording failures surfaced** — `recording_failed_warning` field on response
  instead of silent error swallowing.

## Requirements

- Python 3.10+
- httpx
- pydantic

## License

MIT
