ski-sdk 0.1.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.
- ski_sdk/__init__.py +47 -0
- ski_sdk/client.py +242 -0
- ski_sdk/errors.py +48 -0
- ski_sdk/models.py +145 -0
- ski_sdk/py.typed +0 -0
- ski_sdk/verify.py +80 -0
- ski_sdk-0.1.0.dist-info/METADATA +51 -0
- ski_sdk-0.1.0.dist-info/RECORD +10 -0
- ski_sdk-0.1.0.dist-info/WHEEL +5 -0
- ski_sdk-0.1.0.dist-info/top_level.txt +1 -0
ski_sdk/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ski-sdk — typed Python client for the SKI Framework SKI Model.
|
|
2
|
+
|
|
3
|
+
> Early alpha: wraps an alpha HTTP API; pin your versions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .client import AsyncSKIClient, SKIClient
|
|
9
|
+
from .errors import (
|
|
10
|
+
SKIAuthError,
|
|
11
|
+
SKIError,
|
|
12
|
+
SKIResponseError,
|
|
13
|
+
SKIServiceUnavailable,
|
|
14
|
+
SKITransportError,
|
|
15
|
+
SKIValidationError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
HealthStatus,
|
|
19
|
+
LLMTranscript,
|
|
20
|
+
MeasurementRecord,
|
|
21
|
+
V3Verdict,
|
|
22
|
+
V3VerdictEnvelope,
|
|
23
|
+
VerifierStatus,
|
|
24
|
+
)
|
|
25
|
+
from .verify import VerificationReport, verify_transcript
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AsyncSKIClient",
|
|
31
|
+
"HealthStatus",
|
|
32
|
+
"LLMTranscript",
|
|
33
|
+
"MeasurementRecord",
|
|
34
|
+
"SKIAuthError",
|
|
35
|
+
"SKIClient",
|
|
36
|
+
"SKIError",
|
|
37
|
+
"SKIResponseError",
|
|
38
|
+
"SKIServiceUnavailable",
|
|
39
|
+
"SKITransportError",
|
|
40
|
+
"SKIValidationError",
|
|
41
|
+
"V3Verdict",
|
|
42
|
+
"V3VerdictEnvelope",
|
|
43
|
+
"VerificationReport",
|
|
44
|
+
"VerifierStatus",
|
|
45
|
+
"__version__",
|
|
46
|
+
"verify_transcript",
|
|
47
|
+
]
|
ski_sdk/client.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Synchronous and asynchronous HTTP clients for the SKI Model.
|
|
2
|
+
|
|
3
|
+
Thin wrappers over httpx that send the API key as the ``x-api-key`` header,
|
|
4
|
+
verify TLS by default, parse responses into typed models, and map HTTP status
|
|
5
|
+
codes onto the SDK error hierarchy. Idempotent GETs are retried with backoff;
|
|
6
|
+
non-idempotent POSTs are not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import (
|
|
18
|
+
SKIAuthError,
|
|
19
|
+
SKIResponseError,
|
|
20
|
+
SKIServiceUnavailable,
|
|
21
|
+
SKITransportError,
|
|
22
|
+
SKIValidationError,
|
|
23
|
+
)
|
|
24
|
+
from .models import HealthStatus, MeasurementRecord, V3VerdictEnvelope
|
|
25
|
+
|
|
26
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _detail(resp: httpx.Response) -> Optional[str]:
|
|
30
|
+
try:
|
|
31
|
+
body = resp.json()
|
|
32
|
+
return body.get("detail") if isinstance(body, dict) else None
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
38
|
+
if resp.status_code < 400:
|
|
39
|
+
return
|
|
40
|
+
detail = _detail(resp)
|
|
41
|
+
if resp.status_code == 401:
|
|
42
|
+
raise SKIAuthError(401, detail)
|
|
43
|
+
if resp.status_code == 503:
|
|
44
|
+
raise SKIServiceUnavailable(503, detail)
|
|
45
|
+
if 400 <= resp.status_code < 500:
|
|
46
|
+
raise SKIValidationError(resp.status_code, detail)
|
|
47
|
+
raise SKIResponseError(resp.status_code, detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _headers(api_key: Optional[str]) -> Dict[str, str]:
|
|
51
|
+
return {"x-api-key": api_key} if api_key else {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Base:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
endpoint: str,
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
*,
|
|
60
|
+
verify_tls: Union[bool, str] = True,
|
|
61
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
62
|
+
max_retries: int = 2,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._endpoint = endpoint.rstrip("/")
|
|
65
|
+
self._api_key = api_key
|
|
66
|
+
self._max_retries = max_retries
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str: # never leak the API key
|
|
69
|
+
return (
|
|
70
|
+
f"{type(self).__name__}(endpoint={self._endpoint!r}, api_key={'***' if self._api_key else None})"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SKIClient(_Base):
|
|
75
|
+
"""Synchronous SKI Model client."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
endpoint: str,
|
|
80
|
+
api_key: Optional[str] = None,
|
|
81
|
+
*,
|
|
82
|
+
verify_tls: Union[bool, str] = True,
|
|
83
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
84
|
+
max_retries: int = 2,
|
|
85
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__(endpoint, api_key, verify_tls=verify_tls, timeout=timeout, max_retries=max_retries)
|
|
88
|
+
self._client = httpx.Client(
|
|
89
|
+
base_url=self._endpoint,
|
|
90
|
+
verify=verify_tls,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
headers=_headers(api_key),
|
|
93
|
+
transport=transport,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> SKIClient:
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *exc: object) -> None:
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._client.close()
|
|
104
|
+
|
|
105
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response:
|
|
106
|
+
attempt = 0
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
resp = self._client.get(path, params=params)
|
|
110
|
+
except httpx.RequestError as exc:
|
|
111
|
+
if attempt >= self._max_retries:
|
|
112
|
+
raise SKITransportError(str(exc)) from exc
|
|
113
|
+
else:
|
|
114
|
+
if resp.status_code < 500 or attempt >= self._max_retries:
|
|
115
|
+
_raise_for_status(resp)
|
|
116
|
+
return resp
|
|
117
|
+
time.sleep(0.25 * (2**attempt))
|
|
118
|
+
attempt += 1
|
|
119
|
+
|
|
120
|
+
def _post(self, path: str, json_body: Dict[str, Any]) -> httpx.Response:
|
|
121
|
+
try:
|
|
122
|
+
resp = self._client.post(path, json=json_body)
|
|
123
|
+
except httpx.RequestError as exc:
|
|
124
|
+
raise SKITransportError(str(exc)) from exc
|
|
125
|
+
_raise_for_status(resp)
|
|
126
|
+
return resp
|
|
127
|
+
|
|
128
|
+
def evaluate(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
measurement_id: str,
|
|
132
|
+
timestamp: str,
|
|
133
|
+
subject: str,
|
|
134
|
+
measurement: Dict[str, Any],
|
|
135
|
+
jurisdiction: Optional[str] = None,
|
|
136
|
+
) -> V3VerdictEnvelope:
|
|
137
|
+
body = MeasurementRecord(
|
|
138
|
+
measurement_id=measurement_id,
|
|
139
|
+
timestamp=timestamp,
|
|
140
|
+
subject=subject,
|
|
141
|
+
measurement=measurement,
|
|
142
|
+
jurisdiction=jurisdiction,
|
|
143
|
+
).model_dump()
|
|
144
|
+
return V3VerdictEnvelope.model_validate(self._post("/api/evaluate", body).json())
|
|
145
|
+
|
|
146
|
+
def health(self) -> HealthStatus:
|
|
147
|
+
return HealthStatus.model_validate(self._get("/api/health").json())
|
|
148
|
+
|
|
149
|
+
def list_verdicts(self, *, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
|
150
|
+
return dict(self._get("/api/verdicts", params={"limit": limit, "offset": offset}).json())
|
|
151
|
+
|
|
152
|
+
def load_kg(self, signed_kg: Dict[str, Any]) -> Dict[str, Any]:
|
|
153
|
+
return dict(self._post("/api/kg/load", signed_kg).json())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AsyncSKIClient(_Base):
|
|
157
|
+
"""Asynchronous SKI Model client (mirrors :class:`SKIClient`)."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
endpoint: str,
|
|
162
|
+
api_key: Optional[str] = None,
|
|
163
|
+
*,
|
|
164
|
+
verify_tls: Union[bool, str] = True,
|
|
165
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
166
|
+
max_retries: int = 2,
|
|
167
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
super().__init__(endpoint, api_key, verify_tls=verify_tls, timeout=timeout, max_retries=max_retries)
|
|
170
|
+
self._client = httpx.AsyncClient(
|
|
171
|
+
base_url=self._endpoint,
|
|
172
|
+
verify=verify_tls,
|
|
173
|
+
timeout=timeout,
|
|
174
|
+
headers=_headers(api_key),
|
|
175
|
+
transport=transport,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def __aenter__(self) -> AsyncSKIClient:
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
182
|
+
await self.aclose()
|
|
183
|
+
|
|
184
|
+
async def aclose(self) -> None:
|
|
185
|
+
await self._client.aclose()
|
|
186
|
+
|
|
187
|
+
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response:
|
|
188
|
+
attempt = 0
|
|
189
|
+
while True:
|
|
190
|
+
try:
|
|
191
|
+
resp = await self._client.get(path, params=params)
|
|
192
|
+
except httpx.RequestError as exc:
|
|
193
|
+
if attempt >= self._max_retries:
|
|
194
|
+
raise SKITransportError(str(exc)) from exc
|
|
195
|
+
else:
|
|
196
|
+
if resp.status_code < 500 or attempt >= self._max_retries:
|
|
197
|
+
_raise_for_status(resp)
|
|
198
|
+
return resp
|
|
199
|
+
await asyncio.sleep(0.25 * (2**attempt))
|
|
200
|
+
attempt += 1
|
|
201
|
+
|
|
202
|
+
async def _post(self, path: str, json_body: Dict[str, Any]) -> httpx.Response:
|
|
203
|
+
try:
|
|
204
|
+
resp = await self._client.post(path, json=json_body)
|
|
205
|
+
except httpx.RequestError as exc:
|
|
206
|
+
raise SKITransportError(str(exc)) from exc
|
|
207
|
+
_raise_for_status(resp)
|
|
208
|
+
return resp
|
|
209
|
+
|
|
210
|
+
async def evaluate(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
measurement_id: str,
|
|
214
|
+
timestamp: str,
|
|
215
|
+
subject: str,
|
|
216
|
+
measurement: Dict[str, Any],
|
|
217
|
+
jurisdiction: Optional[str] = None,
|
|
218
|
+
) -> V3VerdictEnvelope:
|
|
219
|
+
body = MeasurementRecord(
|
|
220
|
+
measurement_id=measurement_id,
|
|
221
|
+
timestamp=timestamp,
|
|
222
|
+
subject=subject,
|
|
223
|
+
measurement=measurement,
|
|
224
|
+
jurisdiction=jurisdiction,
|
|
225
|
+
).model_dump()
|
|
226
|
+
resp = await self._post("/api/evaluate", body)
|
|
227
|
+
return V3VerdictEnvelope.model_validate(resp.json())
|
|
228
|
+
|
|
229
|
+
async def health(self) -> HealthStatus:
|
|
230
|
+
resp = await self._get("/api/health")
|
|
231
|
+
return HealthStatus.model_validate(resp.json())
|
|
232
|
+
|
|
233
|
+
async def list_verdicts(self, *, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
|
234
|
+
resp = await self._get("/api/verdicts", params={"limit": limit, "offset": offset})
|
|
235
|
+
return dict(resp.json())
|
|
236
|
+
|
|
237
|
+
async def load_kg(self, signed_kg: Dict[str, Any]) -> Dict[str, Any]:
|
|
238
|
+
resp = await self._post("/api/kg/load", signed_kg)
|
|
239
|
+
return dict(resp.json())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["AsyncSKIClient", "SKIClient"]
|
ski_sdk/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Typed error hierarchy for the SKI SDK.
|
|
2
|
+
|
|
3
|
+
Callers branch on the exception type rather than parsing strings. The API
|
|
4
|
+
key is never included in any message or repr.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SKIError(Exception):
|
|
13
|
+
"""Base class for all SKI SDK errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SKITransportError(SKIError):
|
|
17
|
+
"""Network/TLS failure reaching the SKI Model (no HTTP response)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SKIResponseError(SKIError):
|
|
21
|
+
"""The SKI Model returned an HTTP error status."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_code: int, detail: Optional[str] = None) -> None:
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.detail = detail
|
|
26
|
+
super().__init__(f"SKI Model returned HTTP {status_code}" + (f": {detail}" if detail else ""))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SKIAuthError(SKIResponseError):
|
|
30
|
+
"""401 — missing or invalid API key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SKIServiceUnavailable(SKIResponseError):
|
|
34
|
+
"""503 — service not ready (e.g. no Knowledge Graph loaded)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SKIValidationError(SKIResponseError):
|
|
38
|
+
"""4xx — the request payload was rejected by the SKI Model."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"SKIAuthError",
|
|
43
|
+
"SKIError",
|
|
44
|
+
"SKIResponseError",
|
|
45
|
+
"SKIServiceUnavailable",
|
|
46
|
+
"SKITransportError",
|
|
47
|
+
"SKIValidationError",
|
|
48
|
+
]
|
ski_sdk/models.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Wire-contract models, mirrored from the SKI Model v3 envelope (spec §4-§6).
|
|
2
|
+
|
|
3
|
+
These intentionally duplicate the server's Pydantic models so the SDK has no
|
|
4
|
+
dependency on the reference implementation. A contract-drift test
|
|
5
|
+
(`tests/test_contract_drift.py`) asserts the field sets stay in lock-step with
|
|
6
|
+
the server's `V3VerdictEnvelope` / `MeasurementRecord` / `LLMTranscript`.
|
|
7
|
+
|
|
8
|
+
Response models use ``extra="ignore"`` so a forward-compatible server that adds
|
|
9
|
+
a field does not break older SDKs at runtime; the drift test is what flags the
|
|
10
|
+
divergence in CI.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
_RESPONSE = ConfigDict(use_enum_values=True, extra="ignore", protected_namespaces=())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class V3Verdict(str, Enum):
|
|
25
|
+
CLEAR = "CLEAR"
|
|
26
|
+
FLAG = "FLAG"
|
|
27
|
+
DISCRETIONARY = "DISCRETIONARY"
|
|
28
|
+
NULL_UNMAPPED = "NULL_UNMAPPED"
|
|
29
|
+
NULL_STALE = "NULL_STALE"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VerifierStatus(str, Enum):
|
|
33
|
+
AGREED = "AGREED"
|
|
34
|
+
LLM_CONTRADICTION = "LLM_CONTRADICTION"
|
|
35
|
+
NEURO_SYMBOLIC_DIVERGENCE = "NEURO_SYMBOLIC_DIVERGENCE"
|
|
36
|
+
UNVERIFIABLE = "UNVERIFIABLE"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KGCitationRole(str, Enum):
|
|
40
|
+
OBLIGATION = "obligation"
|
|
41
|
+
DEFINITION = "definition"
|
|
42
|
+
PRECEDENT = "precedent"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KGCitation(BaseModel):
|
|
46
|
+
model_config = _RESPONSE
|
|
47
|
+
node_id: str
|
|
48
|
+
version: str
|
|
49
|
+
role: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FormalizableAssertion(BaseModel):
|
|
53
|
+
model_config = _RESPONSE
|
|
54
|
+
predicate: str
|
|
55
|
+
metric: str
|
|
56
|
+
value: Union[float, int, str, List[Any], None] = None
|
|
57
|
+
observed: Union[float, int, str, List[Any], None] = None
|
|
58
|
+
satisfied: bool
|
|
59
|
+
obligation_id: str
|
|
60
|
+
window_seconds: Optional[int] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VerifierResult(BaseModel):
|
|
64
|
+
model_config = _RESPONSE
|
|
65
|
+
status: str
|
|
66
|
+
checked_assertions: int = 0
|
|
67
|
+
divergences: List[str] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ModelProvenance(BaseModel):
|
|
71
|
+
model_config = _RESPONSE
|
|
72
|
+
model_weight_hash: str
|
|
73
|
+
kg_version_hash: str
|
|
74
|
+
prompt_template_id: str
|
|
75
|
+
prompt_template_hash: str
|
|
76
|
+
decoder_seed: int
|
|
77
|
+
structured_grammar_hash: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class V3VerdictEnvelope(BaseModel):
|
|
81
|
+
model_config = _RESPONSE
|
|
82
|
+
verdict: str
|
|
83
|
+
reasoning: str
|
|
84
|
+
kg_citations: List[KGCitation] = Field(default_factory=list)
|
|
85
|
+
formalizable_assertions: List[FormalizableAssertion] = Field(default_factory=list)
|
|
86
|
+
verifier_result: VerifierResult
|
|
87
|
+
model_provenance: ModelProvenance
|
|
88
|
+
transcript_ref: str
|
|
89
|
+
human_attestation: Optional[Dict[str, Any]] = None
|
|
90
|
+
notes: List[str] = Field(default_factory=list)
|
|
91
|
+
verdict_path: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MeasurementRecord(BaseModel):
|
|
95
|
+
"""Request body for ``POST /api/evaluate``."""
|
|
96
|
+
|
|
97
|
+
model_config = ConfigDict(extra="forbid")
|
|
98
|
+
measurement_id: str
|
|
99
|
+
timestamp: str
|
|
100
|
+
subject: str
|
|
101
|
+
measurement: Dict[str, Any] = Field(default_factory=dict)
|
|
102
|
+
jurisdiction: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class LLMTranscript(BaseModel):
|
|
106
|
+
"""The signed transcript an auditor verifies (spec §6.2)."""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="ignore")
|
|
109
|
+
transcript_id: str
|
|
110
|
+
request_canonical: str
|
|
111
|
+
request_hash: str
|
|
112
|
+
response_canonical: Dict[str, Any]
|
|
113
|
+
response_hash: str
|
|
114
|
+
signature_hex: str
|
|
115
|
+
signing_key_id: str
|
|
116
|
+
backend_name: str
|
|
117
|
+
backend_metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
118
|
+
started_at: datetime
|
|
119
|
+
completed_at: datetime
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class HealthStatus(BaseModel):
|
|
123
|
+
model_config = _RESPONSE
|
|
124
|
+
status: str
|
|
125
|
+
kg_loaded: bool
|
|
126
|
+
kg_signature_verified: bool
|
|
127
|
+
canary_status: str
|
|
128
|
+
verdicts_produced: int
|
|
129
|
+
timestamp: str
|
|
130
|
+
runtime_version: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"FormalizableAssertion",
|
|
135
|
+
"HealthStatus",
|
|
136
|
+
"KGCitation",
|
|
137
|
+
"KGCitationRole",
|
|
138
|
+
"LLMTranscript",
|
|
139
|
+
"MeasurementRecord",
|
|
140
|
+
"ModelProvenance",
|
|
141
|
+
"V3Verdict",
|
|
142
|
+
"V3VerdictEnvelope",
|
|
143
|
+
"VerifierResult",
|
|
144
|
+
"VerifierStatus",
|
|
145
|
+
]
|
ski_sdk/py.typed
ADDED
|
File without changes
|
ski_sdk/verify.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Verify the signed provenance of a verdict's LLM transcript (spec §6.2).
|
|
2
|
+
|
|
3
|
+
Re-implements the framework's canonicalisation and Ed25519 verification so the
|
|
4
|
+
SDK depends only on `cryptography` — not on the reference implementation. The
|
|
5
|
+
runtime signs ``request_hash || "|" || response_hash``; an auditor recomputes
|
|
6
|
+
those hashes from the canonical request/response, then verifies the signature
|
|
7
|
+
against the runtime's public key.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Dict, Mapping, Union
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidSignature
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
19
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
20
|
+
|
|
21
|
+
from .models import LLMTranscript
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha256_prefixed(data: bytes) -> str:
|
|
25
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _canonical_response(response: Mapping[str, Any]) -> bytes:
|
|
29
|
+
return json.dumps(dict(response), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
30
|
+
"utf-8"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class VerificationReport:
|
|
36
|
+
"""Result of verifying a transcript."""
|
|
37
|
+
|
|
38
|
+
signature_valid: bool
|
|
39
|
+
hashes_match: bool
|
|
40
|
+
ok: bool
|
|
41
|
+
|
|
42
|
+
def __bool__(self) -> bool:
|
|
43
|
+
return self.ok
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_transcript(
|
|
47
|
+
transcript: Union[LLMTranscript, Dict[str, Any]],
|
|
48
|
+
public_key_pem: Union[str, bytes],
|
|
49
|
+
*,
|
|
50
|
+
strict: bool = True,
|
|
51
|
+
) -> VerificationReport:
|
|
52
|
+
"""Verify a transcript's Ed25519 signature (and, in strict mode, its hashes).
|
|
53
|
+
|
|
54
|
+
``strict=True`` (default) also recomputes ``request_hash``/``response_hash``
|
|
55
|
+
from the canonical request/response and confirms they match the recorded
|
|
56
|
+
values — catching a response that was altered after signing.
|
|
57
|
+
"""
|
|
58
|
+
t = transcript if isinstance(transcript, LLMTranscript) else LLMTranscript.model_validate(transcript)
|
|
59
|
+
|
|
60
|
+
recomputed_req = _sha256_prefixed(t.request_canonical.encode("utf-8"))
|
|
61
|
+
recomputed_resp = _sha256_prefixed(_canonical_response(t.response_canonical))
|
|
62
|
+
hashes_match = recomputed_req == t.request_hash and recomputed_resp == t.response_hash
|
|
63
|
+
|
|
64
|
+
pem = public_key_pem.encode("ascii") if isinstance(public_key_pem, str) else public_key_pem
|
|
65
|
+
key = load_pem_public_key(pem)
|
|
66
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
67
|
+
raise ValueError("public_key_pem is not an Ed25519 public key.")
|
|
68
|
+
|
|
69
|
+
message = f"{t.request_hash}|{t.response_hash}".encode("ascii")
|
|
70
|
+
try:
|
|
71
|
+
key.verify(bytes.fromhex(t.signature_hex), message)
|
|
72
|
+
signature_valid = True
|
|
73
|
+
except (InvalidSignature, ValueError):
|
|
74
|
+
signature_valid = False
|
|
75
|
+
|
|
76
|
+
ok = signature_valid and (hashes_match or not strict)
|
|
77
|
+
return VerificationReport(signature_valid=signature_valid, hashes_match=hashes_match, ok=ok)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["VerificationReport", "verify_transcript"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ski-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the SKI Framework SKI Model, with one-call provenance verification.
|
|
5
|
+
Author: KpiFinity Inc.
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/kpifinity/ski-framework
|
|
8
|
+
Project-URL: Documentation, https://kpifinity.github.io/ski-framework/
|
|
9
|
+
Project-URL: Issues, https://github.com/kpifinity/ski-framework/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.13.4
|
|
18
|
+
Requires-Dist: cryptography<48,>=46
|
|
19
|
+
|
|
20
|
+
# ski-sdk
|
|
21
|
+
|
|
22
|
+
> Install from PyPI: `pip install ski-sdk` (publishing starts with the first release after June 2026).
|
|
23
|
+
|
|
24
|
+
Typed Python client for the [SKI Framework](https://github.com/kpifinity/ski-framework)
|
|
25
|
+
SKI Model, with one-call verification of a verdict's signed provenance.
|
|
26
|
+
|
|
27
|
+
> **Status: early alpha.** This wraps an alpha HTTP API; pin your versions. The
|
|
28
|
+
> SDK is versioned independently of the framework — see the compatibility note
|
|
29
|
+
> in the docs.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ski_sdk import SKIClient
|
|
33
|
+
|
|
34
|
+
client = SKIClient(endpoint="https://ski.internal:8000", api_key="…")
|
|
35
|
+
env = client.evaluate(
|
|
36
|
+
measurement_id="m-001",
|
|
37
|
+
timestamp="2026-06-05T12:00:00Z",
|
|
38
|
+
subject="stack-7",
|
|
39
|
+
measurement={"so2_ppm": 150},
|
|
40
|
+
)
|
|
41
|
+
print(env.verdict, [c.node_id for c in env.kg_citations]) # FLAG ['energy.so2.lte_100ppm']
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Verify a verdict's signed transcript (tamper-evident provenance):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from ski_sdk import verify_transcript
|
|
48
|
+
|
|
49
|
+
report = verify_transcript(transcript, public_key_pem)
|
|
50
|
+
assert report.ok # signature valid AND recorded hashes match the canonical pair
|
|
51
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ski_sdk/__init__.py,sha256=UmYrKdo5ELLyPnkq11blKR2DkXGevUeklgjOPoPaBOo,984
|
|
2
|
+
ski_sdk/client.py,sha256=huuDLjDuV1sOI8bTT40R9K8qWdst293MeG7fm8k9fmA,7824
|
|
3
|
+
ski_sdk/errors.py,sha256=B9nveH9_srhwPRellx9HoQQ0AW3mGUWVu5g-B3U6WOU,1235
|
|
4
|
+
ski_sdk/models.py,sha256=K6_mkv8Gp8yXgbr2rbpy6bt0rVLyvkeSh6C-gGa4rc8,3891
|
|
5
|
+
ski_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ski_sdk/verify.py,sha256=dERYk5-u-KgIpZdcRYYD9MYGeTIviYtwv8HxIEuORRQ,2878
|
|
7
|
+
ski_sdk-0.1.0.dist-info/METADATA,sha256=Kab6MIiLjowBqZJBYnHOKuTmHJUiEK3Ahf47SdNRJ9Q,1840
|
|
8
|
+
ski_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
ski_sdk-0.1.0.dist-info/top_level.txt,sha256=wXpUfrUOWsOSAPCLcuQ7fo6mKx1cjurpveP9keBPfKQ,8
|
|
10
|
+
ski_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ski_sdk
|