Skip to content
Closed
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Changelog

## v0.2.0 — server sync

- Refactored package into modular files:
- `panel_sdk/client.py`
- `panel_sdk/rater.py`
- `panel_sdk/_signing.py`
- `panel_sdk/_scrubber.py`
- `panel_sdk/_retry.py`
- `panel_sdk/errors.py`
- `panel_sdk/types.py`
- Added operator-side methods: `ingest_units`, `score_unit`, `skill_review`, `ingest_trace_and_wait`
- Added typed trace ingest results (`TraceResult`) for sync/pending responses
- Added dual-secret support with `site_secret_source="raw"` and `x-panel-ingest-secret`
- Added scrubber dispatch modes: `off`, `self-sign`, `proxy`
- Added typed errors: `PanelRateLimitError`, `PanelScrubberError`
- Added rater clients: `RaterClient`, `AsyncRaterClient`
- Added 429/5xx retry and backoff behavior
- Added unit tests covering HMAC signing, scrubber JWT, async trace polling, and 429 retry
- Kept `verify_token(token)` public signature unchanged
50 changes: 37 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
# panel-sdk (python)

thin client for [panel](https://github.com/UltraInstinct0x/panel). python 3.10+.
Thin client for [panel](https://github.com/UltraInstinct0x/panel). Python 3.10+.

```
## Install

```bash
pip install panel-sdk
```

## v0.2.0 options

```python
from panel_sdk import PanelClient

panel = PanelClient(
base_url="https://panel.example.com",
site_key=os.environ["PANEL_SITE_KEY"],
site_secret=os.environ["PANEL_SITE_SECRET"],
scrubber_secret=os.environ.get("SCRUBBER_JWT_SECRET"), # omit for first-party keys
site_key="pk_live_xxx",
site_secret="secret",
site_secret_source="env", # or "raw" for dual-secret mode
scrubber_mode="off", # off | self-sign | proxy
scrubber_secret=None, # required when scrubber_mode="self-sign"
scrubber_url=None, # required when scrubber_mode="proxy"
engine_version="0.2.0",
timeout_seconds=10.0,
max_retries=3,
)
```

v = panel.verify_token(request.json["panel_token"])
if not v.ok or (v.trust or 0) < 0.5:
abort(403)
## PanelClient methods

panel.ingest_trace(trace_id=f"tr_{uuid4()}", source_agent="myapp",
blob={"messages": [...]})
```
- `ingest_trace(source_agent, blob, trace_id=None) -> TraceResult`
- `ingest_trace_and_wait(source_agent, blob, trace_id=None, max_wait_seconds=60, poll_interval_seconds=1.5) -> dict`
- `fetch_trace(trace_id) -> dict`
- `ingest_units(units) -> dict`
- `score_unit(ref=None, unit_id=None) -> dict`
- `skill_review(skill_name, diff, ...) -> dict`
- `verify_token(token) -> VerifyResult`

`verify_token(token)` signature remains unchanged.

## Rater clients

- `RaterClient.next_unit(pool, rater_id)`
- `RaterClient.submit_judgment(unit_id, choice)`
- Async parity via `AsyncRaterClient`

## Errors

methods (sync + async via `AsyncPanelClient`): `ingest_unit`, `ingest_trace`, `verify_token`, `fetch_unit`, `fetch_trace`.
auth: HMAC-SHA256 (`x-panel-ingest-sig`) + optional scrubber JWT.
- `PanelError`
- `PanelRateLimitError` (includes `scope`, `retry_after_s`)
- `PanelScrubberError`
212 changes: 20 additions & 192 deletions panel_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,192 +1,20 @@
"""panel_sdk — thin client for the panel HTTP api.

usage:
from panel_sdk import PanelClient
c = PanelClient(base_url="https://panel.example.com",
site_key="pk_live_xxx", site_secret="...",
scrubber_secret="...") # omit for first-party keys
c.verify_token(token)
"""
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
from typing import Any, Mapping, MutableMapping

import httpx

__version__ = "0.1.0"
__all__ = ["PanelClient", "AsyncPanelClient", "PanelError", "VerifyResult"]


class PanelError(Exception):
def __init__(self, status: int, body: Any, message: str) -> None:
super().__init__(message)
self.status = status
self.body = body


@dataclass
class VerifyResult:
ok: bool
trust: float | None = None
tier_used: str | None = None
unit_ids: list[str] | None = None
reason: str | None = None

@classmethod
def from_json(cls, j: Mapping[str, Any]) -> "VerifyResult":
return cls(
ok=bool(j.get("ok")),
trust=j.get("trust"),
tier_used=j.get("tier_used"),
unit_ids=j.get("unit_ids"),
reason=j.get("reason"),
)


def _b64u(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")


def _hmac_hex(secret: str, body: str) -> str:
return hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()


def _sha256_hex(s: str) -> str:
return hashlib.sha256(s.encode()).hexdigest()


def _jwt_hs256(secret: str, payload: Mapping[str, Any]) -> str:
header = {"alg": "HS256", "typ": "JWT"}
si = f"{_b64u(json.dumps(header, separators=(',', ':')).encode())}.{_b64u(json.dumps(payload, separators=(',', ':')).encode())}"
sig = _b64u(hmac.new(secret.encode(), si.encode(), hashlib.sha256).digest())
return f"{si}.{sig}"


def _attest(scrubber_secret: str, body: str, engine_version: str) -> str:
now = int(time.time())
return _jwt_hs256(scrubber_secret, {
"jti": secrets.token_hex(16),
"iat": now,
"exp": now + 300,
"input_hash": "x",
"output_hash": _sha256_hex(body),
"mode": "text",
"engine_version": engine_version,
})


def _check(resp: httpx.Response) -> Any:
try:
j = resp.json()
except Exception:
j = {"raw": resp.text}
if not (200 <= resp.status_code < 300):
raise PanelError(resp.status_code, j, f"panel {resp.status_code}: {resp.text[:300]}")
return j


def _ingest_headers(site_key: str, site_secret: str, body: str,
scrubber_secret: str | None, engine_version: str) -> MutableMapping[str, str]:
h: MutableMapping[str, str] = {
"content-type": "application/json",
"x-panel-site-key": site_key,
"x-panel-ingest-sig": _hmac_hex(site_secret, body),
}
if scrubber_secret:
h["x-scrubber-attestation"] = _attest(scrubber_secret, body, engine_version)
return h


class PanelClient:
def __init__(self, base_url: str, site_key: str, site_secret: str,
scrubber_secret: str | None = None, engine_version: str = "0.2.0",
client: httpx.Client | None = None, timeout: float = 10.0) -> None:
self.base = base_url.rstrip("/")
self.site_key = site_key
self.site_secret = site_secret
self.scrubber_secret = scrubber_secret
self.engine_version = engine_version
self._client = client or httpx.Client(timeout=timeout)

def ingest_unit(self, *, type: str, payload: dict, pool: str | None = None) -> dict:
d: dict[str, Any] = {"type": type, "payload": payload}
if pool is not None: d["pool"] = pool
body = json.dumps(d, separators=(",", ":"))
r = self._client.post(self.base + "/api/units/ingest",
headers=_ingest_headers(self.site_key, self.site_secret, body,
self.scrubber_secret, self.engine_version),
content=body)
return _check(r)

def ingest_trace(self, *, trace_id: str, source_agent: str, blob: dict) -> dict:
body = json.dumps({"trace_id": trace_id, "source_agent": source_agent, "blob": blob}, separators=(",", ":"))
r = self._client.post(self.base + "/api/v1/traces",
headers=_ingest_headers(self.site_key, self.site_secret, body,
self.scrubber_secret, self.engine_version),
content=body)
return _check(r)

def verify_token(self, token: str) -> VerifyResult:
r = self._client.post(self.base + "/v1/verify",
headers={"content-type": "application/json"},
json={"token": token, "site_key": self.site_key})
return VerifyResult.from_json(_check(r))

def fetch_unit(self, unit_id: str) -> dict:
r = self._client.get(f"{self.base}/api/units/{unit_id}")
return _check(r)

def fetch_trace(self, trace_id: str) -> dict:
r = self._client.get(f"{self.base}/api/v1/traces/{trace_id}")
return _check(r)


class AsyncPanelClient:
def __init__(self, base_url: str, site_key: str, site_secret: str,
scrubber_secret: str | None = None, engine_version: str = "0.2.0",
client: httpx.AsyncClient | None = None, timeout: float = 10.0) -> None:
self.base = base_url.rstrip("/")
self.site_key = site_key
self.site_secret = site_secret
self.scrubber_secret = scrubber_secret
self.engine_version = engine_version
self._client = client or httpx.AsyncClient(timeout=timeout)

async def ingest_unit(self, *, type: str, payload: dict, pool: str | None = None) -> dict:
d: dict[str, Any] = {"type": type, "payload": payload}
if pool is not None: d["pool"] = pool
body = json.dumps(d, separators=(",", ":"))
r = await self._client.post(self.base + "/api/units/ingest",
headers=_ingest_headers(self.site_key, self.site_secret, body,
self.scrubber_secret, self.engine_version),
content=body)
return _check(r)

async def ingest_trace(self, *, trace_id: str, source_agent: str, blob: dict) -> dict:
body = json.dumps({"trace_id": trace_id, "source_agent": source_agent, "blob": blob}, separators=(",", ":"))
r = await self._client.post(self.base + "/api/v1/traces",
headers=_ingest_headers(self.site_key, self.site_secret, body,
self.scrubber_secret, self.engine_version),
content=body)
return _check(r)

async def verify_token(self, token: str) -> VerifyResult:
r = await self._client.post(self.base + "/v1/verify",
headers={"content-type": "application/json"},
json={"token": token, "site_key": self.site_key})
return VerifyResult.from_json(_check(r))

async def fetch_unit(self, unit_id: str) -> dict:
r = await self._client.get(f"{self.base}/api/units/{unit_id}")
return _check(r)

async def fetch_trace(self, trace_id: str) -> dict:
r = await self._client.get(f"{self.base}/api/v1/traces/{trace_id}")
return _check(r)
"""Public package exports for panel-sdk."""

from panel_sdk.client import AsyncPanelClient, PanelClient
from panel_sdk.errors import PanelError, PanelRateLimitError, PanelScrubberError
from panel_sdk.rater import AsyncRaterClient, RaterClient
from panel_sdk.types import TraceResult, VerifyResult

__version__ = "0.2.0"

__all__ = [
"AsyncPanelClient",
"AsyncRaterClient",
"PanelClient",
"PanelError",
"PanelRateLimitError",
"PanelScrubberError",
"RaterClient",
"TraceResult",
"VerifyResult",
]
43 changes: 43 additions & 0 deletions panel_sdk/_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""HTTP retry helpers for rate-limit and server errors."""

from __future__ import annotations

import time
from typing import Any

from panel_sdk.errors import PanelError, PanelRateLimitError

SERVER_BACKOFF_S = (0.5, 1.5, 3.5)


def parse_error_response(status_code: int, data: Any, text: str) -> Any:
"""Raise typed errors for non-success responses."""
if status_code == 429:
scope = data.get("scope") if isinstance(data, dict) else None
retry_after = data.get("retry_after_s") if isinstance(data, dict) else None
if isinstance(retry_after, int):
retry_after = float(retry_after)
if retry_after is not None and not isinstance(retry_after, float):
retry_after = None
raise PanelRateLimitError(status=429, body=data, scope=scope, retry_after_s=retry_after)
raise PanelError(status=status_code, body=data, message=f"panel {status_code}: {text[:300]}")


def should_retry(status_code: int, attempt: int, max_retries: int) -> bool:
"""Return whether a request should be retried for this status."""
if attempt >= max_retries:
return False
return status_code == 429 or status_code >= 500


def retry_delay_seconds(status_code: int, attempt: int, retry_after_s: float | None) -> float:
"""Return capped sleep duration before next retry."""
if status_code == 429:
return min(30.0, retry_after_s or 1.0)
idx = min(attempt, len(SERVER_BACKOFF_S) - 1)
return SERVER_BACKOFF_S[idx]


def blocking_sleep(seconds: float) -> None:
"""Sleep helper for sync retries."""
time.sleep(seconds)
Loading