ski-sdk 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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,32 @@
1
+ # ski-sdk
2
+
3
+ > Install from PyPI: `pip install ski-sdk` (publishing starts with the first release after June 2026).
4
+
5
+ Typed Python client for the [SKI Framework](https://github.com/kpifinity/ski-framework)
6
+ SKI Model, with one-call verification of a verdict's signed provenance.
7
+
8
+ > **Status: early alpha.** This wraps an alpha HTTP API; pin your versions. The
9
+ > SDK is versioned independently of the framework — see the compatibility note
10
+ > in the docs.
11
+
12
+ ```python
13
+ from ski_sdk import SKIClient
14
+
15
+ client = SKIClient(endpoint="https://ski.internal:8000", api_key="…")
16
+ env = client.evaluate(
17
+ measurement_id="m-001",
18
+ timestamp="2026-06-05T12:00:00Z",
19
+ subject="stack-7",
20
+ measurement={"so2_ppm": 150},
21
+ )
22
+ print(env.verdict, [c.node_id for c in env.kg_citations]) # FLAG ['energy.so2.lte_100ppm']
23
+ ```
24
+
25
+ Verify a verdict's signed transcript (tamper-evident provenance):
26
+
27
+ ```python
28
+ from ski_sdk import verify_transcript
29
+
30
+ report = verify_transcript(transcript, public_key_pem)
31
+ assert report.ok # signature valid AND recorded hashes match the canonical pair
32
+ ```
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ski-sdk"
7
+ version = "0.1.0"
8
+ description = "Typed Python client for the SKI Framework SKI Model, with one-call provenance verification."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "KpiFinity Inc." }]
13
+ dependencies = [
14
+ "httpx>=0.27,<0.29",
15
+ "pydantic>=2.13.4,<3",
16
+ "cryptography>=46,<48",
17
+ ]
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/kpifinity/ski-framework"
27
+ Documentation = "https://kpifinity.github.io/ski-framework/"
28
+ Issues = "https://github.com/kpifinity/ski-framework/issues"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ ski_sdk = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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"]
@@ -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
+ ]
@@ -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
+ ]
File without changes
@@ -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,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/ski_sdk/__init__.py
4
+ src/ski_sdk/client.py
5
+ src/ski_sdk/errors.py
6
+ src/ski_sdk/models.py
7
+ src/ski_sdk/py.typed
8
+ src/ski_sdk/verify.py
9
+ src/ski_sdk.egg-info/PKG-INFO
10
+ src/ski_sdk.egg-info/SOURCES.txt
11
+ src/ski_sdk.egg-info/dependency_links.txt
12
+ src/ski_sdk.egg-info/requires.txt
13
+ src/ski_sdk.egg-info/top_level.txt
14
+ tests/test_sdk_client.py
15
+ tests/test_sdk_contract_drift.py
16
+ tests/test_sdk_verify.py
@@ -0,0 +1,3 @@
1
+ httpx<0.29,>=0.27
2
+ pydantic<3,>=2.13.4
3
+ cryptography<48,>=46
@@ -0,0 +1 @@
1
+ ski_sdk
@@ -0,0 +1,73 @@
1
+ """SKIClient request/response handling and error mapping.
2
+
3
+ Uses httpx's built-in MockTransport (injected via the client's ``transport``
4
+ parameter) — no pytest-httpx dependency, and version-agnostic across httpx 0.27/0.28.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+ import pytest
11
+ from ski_sdk import SKIAuthError, SKIClient, SKIServiceUnavailable
12
+
13
+ BASE = "https://ski.test:8000"
14
+
15
+ _ENVELOPE = {
16
+ "verdict": "FLAG",
17
+ "reasoning": "breach",
18
+ "kg_citations": [],
19
+ "formalizable_assertions": [],
20
+ "verifier_result": {"status": "AGREED", "checked_assertions": 1, "divergences": []},
21
+ "model_provenance": {
22
+ "model_weight_hash": "sha256:" + "f" * 64,
23
+ "kg_version_hash": "sha256:" + "a" * 64,
24
+ "prompt_template_id": "ski.v3.evaluate.1",
25
+ "prompt_template_hash": "sha256:" + "1" * 64,
26
+ "decoder_seed": 0,
27
+ "structured_grammar_hash": "sha256:" + "2" * 64,
28
+ },
29
+ "transcript_ref": "transcript:abc",
30
+ "notes": [],
31
+ }
32
+
33
+
34
+ def _client(handler, **kw) -> SKIClient:
35
+ return SKIClient(BASE, api_key=kw.pop("api_key", "s3cret"), transport=httpx.MockTransport(handler), **kw)
36
+
37
+
38
+ def test_evaluate_parses_envelope_and_sends_api_key() -> None:
39
+ seen = []
40
+
41
+ def handler(request: httpx.Request) -> httpx.Response:
42
+ seen.append(request)
43
+ return httpx.Response(200, json=_ENVELOPE)
44
+
45
+ with _client(handler) as client:
46
+ env = client.evaluate(
47
+ measurement_id="m1",
48
+ timestamp="2026-06-05T12:00:00Z",
49
+ subject="emissions",
50
+ measurement={"so2_ppm": 150},
51
+ )
52
+ assert env.verdict == "FLAG"
53
+ assert env.verifier_result.status == "AGREED"
54
+ assert seen[0].headers["x-api-key"] == "s3cret"
55
+ assert seen[0].url.path == "/api/evaluate"
56
+
57
+
58
+ def test_401_maps_to_auth_error() -> None:
59
+ with (
60
+ _client(
61
+ lambda r: httpx.Response(401, json={"detail": "Invalid or missing API key."}), api_key="bad"
62
+ ) as client,
63
+ pytest.raises(SKIAuthError),
64
+ ):
65
+ client.evaluate(measurement_id="m1", timestamp="t", subject="s", measurement={})
66
+
67
+
68
+ def test_503_maps_to_service_unavailable() -> None:
69
+ with (
70
+ _client(lambda r: httpx.Response(503, json={"detail": "No KG loaded."}), max_retries=0) as client,
71
+ pytest.raises(SKIServiceUnavailable),
72
+ ):
73
+ client.health()
@@ -0,0 +1,41 @@
1
+ """Contract-drift guard: SDK models must match the server's field-for-field.
2
+
3
+ Skips when the reference implementation is not importable (so the SDK package
4
+ stays independently testable); runs in the repo CI where `ski_model` is on the
5
+ path. If the server changes a model, this test fails and the SDK must follow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ pytest.importorskip("ski_model.v3.envelope")
13
+
14
+ from ski_model.v3 import envelope as srv
15
+ from ski_model.v3 import transcript as srv_t
16
+ from ski_sdk import models as sdk
17
+
18
+
19
+ def _fields(model: object) -> set:
20
+ return set(model.model_fields) # type: ignore[attr-defined]
21
+
22
+
23
+ def test_envelope_and_nested_models_match() -> None:
24
+ pairs = [
25
+ (srv.V3VerdictEnvelope, sdk.V3VerdictEnvelope),
26
+ (srv.KGCitation, sdk.KGCitation),
27
+ (srv.FormalizableAssertion, sdk.FormalizableAssertion),
28
+ (srv.VerifierResult, sdk.VerifierResult),
29
+ (srv.ModelProvenance, sdk.ModelProvenance),
30
+ (srv_t.LLMTranscript, sdk.LLMTranscript),
31
+ ]
32
+ for server_model, sdk_model in pairs:
33
+ sf, df = _fields(server_model), _fields(sdk_model)
34
+ assert sf == df, f"{server_model.__name__} drift: only-server={sf - df} only-sdk={df - sf}"
35
+
36
+
37
+ def test_measurement_record_matches() -> None:
38
+ srv_server = pytest.importorskip("ski_model.server")
39
+ sf = set(srv_server.MeasurementRecord.model_fields)
40
+ df = set(sdk.MeasurementRecord.model_fields)
41
+ assert sf == df, f"MeasurementRecord drift: only-server={sf - df} only-sdk={df - sf}"
@@ -0,0 +1,71 @@
1
+ """verify_transcript: a valid transcript verifies; a tampered one does not."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
10
+ from ski_sdk import verify_transcript
11
+
12
+
13
+ def _h(b: bytes) -> str:
14
+ return "sha256:" + hashlib.sha256(b).hexdigest()
15
+
16
+
17
+ def _build_signed_transcript(key: Ed25519PrivateKey) -> dict:
18
+ request_canonical = "PROMPT: evaluate so2_ppm=50 against energy.so2.lte_100ppm"
19
+ response_canonical = {"verdict": "CLEAR", "kg_citations": [{"node_id": "energy.so2.lte_100ppm"}]}
20
+ req_hash = _h(request_canonical.encode("utf-8"))
21
+ resp_hash = _h(
22
+ json.dumps(response_canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
23
+ "utf-8"
24
+ )
25
+ )
26
+ sig = key.sign(f"{req_hash}|{resp_hash}".encode("ascii")).hex()
27
+ return {
28
+ "transcript_id": "t-1",
29
+ "request_canonical": request_canonical,
30
+ "request_hash": req_hash,
31
+ "response_canonical": response_canonical,
32
+ "response_hash": resp_hash,
33
+ "signature_hex": sig,
34
+ "signing_key_id": "sha256:" + "0" * 64,
35
+ "backend_name": "fake-llm",
36
+ "backend_metadata": {},
37
+ "started_at": "2026-06-05T12:00:00Z",
38
+ "completed_at": "2026-06-05T12:00:01Z",
39
+ }
40
+
41
+
42
+ def _pem(key: Ed25519PrivateKey) -> str:
43
+ return (
44
+ key.public_key()
45
+ .public_bytes(
46
+ encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
47
+ )
48
+ .decode("ascii")
49
+ )
50
+
51
+
52
+ def test_valid_transcript_verifies() -> None:
53
+ key = Ed25519PrivateKey.generate()
54
+ report = verify_transcript(_build_signed_transcript(key), _pem(key))
55
+ assert report.signature_valid and report.hashes_match and report.ok and bool(report)
56
+
57
+
58
+ def test_tampered_response_fails() -> None:
59
+ key = Ed25519PrivateKey.generate()
60
+ t = _build_signed_transcript(key)
61
+ t["response_canonical"]["verdict"] = "FLAG" # tamper after signing
62
+ report = verify_transcript(t, _pem(key))
63
+ assert not report.hashes_match
64
+ assert not report.ok
65
+
66
+
67
+ def test_wrong_key_fails() -> None:
68
+ t = _build_signed_transcript(Ed25519PrivateKey.generate())
69
+ report = verify_transcript(t, _pem(Ed25519PrivateKey.generate()))
70
+ assert not report.signature_valid
71
+ assert not report.ok