kitefrost-core 1.0.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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ .venv/
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: kitefrost-core
3
+ Version: 1.0.0
4
+ Summary: Shared runtime core (transport, auth, errors, shared-core resource clients) that every per-pack KiteFrost SDK depends on (PPI-3, D-PPI-SDK-BUNDLING Option A).
5
+ Project-URL: Homepage, https://kitefrost.ai
6
+ Project-URL: Repository, https://github.com/kitefrost/python-sdk
7
+ Project-URL: Issues, https://github.com/kitefrost/python-sdk/issues
8
+ License: MIT
9
+ Keywords: ai,core,kitefrost,runtime,sdk
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
22
+ Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # kitefrost-core
28
+
29
+ Shared runtime core for the KiteFrost per-pack SDKs (PPI-3, D-PPI-SDK-BUNDLING
30
+ Option A). Importable as `kitefrost_core`.
31
+
32
+ It owns the cross-pack infrastructure surface so each per-pack SDK does not
33
+ duplicate it:
34
+
35
+ - HTTP transport (single httpx connection pool)
36
+ - Auth provider with one-shot 401 refresh
37
+ - Typed error hierarchy (`KiteFrostError` and subclasses)
38
+ - The SHARED_TAGS resource clients (auth, keys, billing, projects, events,
39
+ context, byok, webhooks, health)
40
+ - A lockstep version stamp (`CORE_VERSION`) the per-pack SDKs pin
41
+
42
+ ```python
43
+ from kitefrost_core import KiteFrostCore
44
+
45
+ core = KiteFrostCore(api_key="sk_live_...")
46
+ print(core.health.check())
47
+ ```
48
+
49
+ When a customer installs more than one per-pack SDK, the packs share a single
50
+ `KiteFrostCore` instance, so they share one auth provider and one connection
51
+ pool (the Option-A DX win).
@@ -0,0 +1,25 @@
1
+ # kitefrost-core
2
+
3
+ Shared runtime core for the KiteFrost per-pack SDKs (PPI-3, D-PPI-SDK-BUNDLING
4
+ Option A). Importable as `kitefrost_core`.
5
+
6
+ It owns the cross-pack infrastructure surface so each per-pack SDK does not
7
+ duplicate it:
8
+
9
+ - HTTP transport (single httpx connection pool)
10
+ - Auth provider with one-shot 401 refresh
11
+ - Typed error hierarchy (`KiteFrostError` and subclasses)
12
+ - The SHARED_TAGS resource clients (auth, keys, billing, projects, events,
13
+ context, byok, webhooks, health)
14
+ - A lockstep version stamp (`CORE_VERSION`) the per-pack SDKs pin
15
+
16
+ ```python
17
+ from kitefrost_core import KiteFrostCore
18
+
19
+ core = KiteFrostCore(api_key="sk_live_...")
20
+ print(core.health.check())
21
+ ```
22
+
23
+ When a customer installs more than one per-pack SDK, the packs share a single
24
+ `KiteFrostCore` instance, so they share one auth provider and one connection
25
+ pool (the Option-A DX win).
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kitefrost-core"
7
+ version = "1.0.0"
8
+ description = "Shared runtime core (transport, auth, errors, shared-core resource clients) that every per-pack KiteFrost SDK depends on (PPI-3, D-PPI-SDK-BUNDLING Option A)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["ai", "kitefrost", "sdk", "core", "runtime"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://kitefrost.ai"
29
+ Repository = "https://github.com/kitefrost/python-sdk"
30
+ Issues = "https://github.com/kitefrost/python-sdk/issues"
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ "pytest-httpx>=0.30.0",
36
+ "ruff>=0.8.0",
37
+ "mypy>=1.10.0",
38
+ ]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/kitefrost_core"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ pythonpath = ["src"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py310"
49
+ line-length = 100
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "W"]
53
+
54
+ [tool.ruff.lint.per-file-ignores]
55
+ "tests/**/*.py" = ["E501"]
56
+ "src/**/*.py" = ["E501"]
57
+
58
+ [tool.mypy]
59
+ python_version = "3.10"
60
+ strict = true
61
+ ignore_missing_imports = true
@@ -0,0 +1,62 @@
1
+ """kitefrost-core - shared runtime for every per-pack KiteFrost SDK.
2
+
3
+ Owns HTTP transport, auth (incl. one-shot 401 refresh), error types, and the
4
+ SHARED_TAGS resource clients. Per-pack SDKs depend on this package and add only
5
+ their pack-specific resources on top (PPI-3, D-PPI-SDK-BUNDLING Option A).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .auth import ApiKeyAuth, AuthProvider, RefreshableTokenAuth
11
+ from .core import KiteFrostCore
12
+ from .exceptions import (
13
+ AuthError,
14
+ KiteFrostError,
15
+ NotFoundError,
16
+ RateLimited,
17
+ ServerError,
18
+ ValidationError,
19
+ )
20
+ from .resources import (
21
+ AuthResource,
22
+ BillingResource,
23
+ ByokResource,
24
+ ContextResource,
25
+ EventsResource,
26
+ HealthResource,
27
+ KeysResource,
28
+ ProjectsResource,
29
+ WebhooksResource,
30
+ )
31
+ from .transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
32
+ from .version import CORE_VERSION, assert_core_version_compatible
33
+
34
+ __version__ = CORE_VERSION
35
+
36
+ __all__ = [
37
+ "KiteFrostCore",
38
+ "Transport",
39
+ "AuthProvider",
40
+ "ApiKeyAuth",
41
+ "RefreshableTokenAuth",
42
+ "KiteFrostError",
43
+ "AuthError",
44
+ "NotFoundError",
45
+ "ValidationError",
46
+ "RateLimited",
47
+ "ServerError",
48
+ "AuthResource",
49
+ "KeysResource",
50
+ "BillingResource",
51
+ "ProjectsResource",
52
+ "EventsResource",
53
+ "ContextResource",
54
+ "ByokResource",
55
+ "WebhooksResource",
56
+ "HealthResource",
57
+ "DEFAULT_BASE_URL",
58
+ "DEFAULT_TIMEOUT",
59
+ "CORE_VERSION",
60
+ "assert_core_version_compatible",
61
+ "__version__",
62
+ ]
@@ -0,0 +1,59 @@
1
+ """Single auth provider for the shared core.
2
+
3
+ The Option-A DX win (D-PPI-SDK-BUNDLING): when a customer installs more than one
4
+ per-pack SDK, they share ONE AuthProvider instance (one credential + one
5
+ in-flight refresh) instead of each pack carrying its own token state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable, Protocol, runtime_checkable
11
+
12
+
13
+ @runtime_checkable
14
+ class AuthProvider(Protocol):
15
+ """Supplies the Authorization header and a one-shot refresh after a 401."""
16
+
17
+ def auth_header(self) -> str:
18
+ """Return the current ``Authorization`` header value."""
19
+ ...
20
+
21
+ def refresh(self) -> str | None:
22
+ """Force a one-shot credential refresh and return the new header.
23
+
24
+ Returns ``None`` when this provider cannot refresh (static API keys),
25
+ so the transport surfaces the 401 unchanged.
26
+ """
27
+ ...
28
+
29
+
30
+ class ApiKeyAuth:
31
+ """Static API-key auth. Cannot refresh; a 401 is surfaced as AuthError."""
32
+
33
+ def __init__(self, api_key: str) -> None:
34
+ self._api_key = api_key
35
+
36
+ def auth_header(self) -> str:
37
+ return f"Bearer {self._api_key}"
38
+
39
+ def refresh(self) -> str | None:
40
+ return None
41
+
42
+
43
+ class RefreshableTokenAuth:
44
+ """Bearer-token auth with one-shot refresh via a caller-supplied callback."""
45
+
46
+ def __init__(self, initial_token: str, refresh_fn: Callable[[], str]) -> None:
47
+ self._token = initial_token
48
+ self._refresh_fn = refresh_fn
49
+
50
+ def auth_header(self) -> str:
51
+ return f"Bearer {self._token}"
52
+
53
+ def refresh(self) -> str | None:
54
+ # Not concurrency-coalesced (unlike the TS RefreshableTokenAuth's inflight
55
+ # promise): the sync httpx.Client model serializes requests, so parallel
56
+ # 401s cannot occur. If an async Python transport is ever added, wrap
57
+ # refresh_fn so concurrent 401s share one refresh.
58
+ self._token = self._refresh_fn()
59
+ return f"Bearer {self._token}"
@@ -0,0 +1,74 @@
1
+ """Single configuration entry for the shared core.
2
+
3
+ Constructing one ``KiteFrostCore`` yields ONE auth provider + one Transport
4
+ (one httpx connection pool) that every shared-core resource client reuses.
5
+ Per-pack SDKs accept an existing ``KiteFrostCore`` instance so a customer
6
+ running multiple packs shares a single auth provider / connection pool - the
7
+ Option-A DX win (D-PPI-SDK-BUNDLING).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import httpx
13
+
14
+ from .auth import ApiKeyAuth, AuthProvider
15
+ from .resources import (
16
+ AuthResource,
17
+ BillingResource,
18
+ ByokResource,
19
+ ContextResource,
20
+ EventsResource,
21
+ HealthResource,
22
+ KeysResource,
23
+ ProjectsResource,
24
+ WebhooksResource,
25
+ )
26
+ from .transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
27
+ from .version import CORE_VERSION
28
+
29
+
30
+ class KiteFrostCore:
31
+ """Shared-core entry point. Provide ``api_key`` OR an ``auth`` provider."""
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str | None = None,
36
+ *,
37
+ auth: AuthProvider | None = None,
38
+ base_url: str = DEFAULT_BASE_URL,
39
+ timeout: float = DEFAULT_TIMEOUT,
40
+ http_client: httpx.Client | None = None,
41
+ user_agent: str | None = None,
42
+ ) -> None:
43
+ if api_key is None and auth is None:
44
+ raise ValueError("KiteFrostCore requires either `api_key` or `auth`.")
45
+
46
+ self.version = CORE_VERSION
47
+ auth_provider: AuthProvider = auth if auth is not None else ApiKeyAuth(api_key) # type: ignore[arg-type]
48
+
49
+ self.transport = Transport(
50
+ auth=auth_provider,
51
+ base_url=base_url,
52
+ timeout=timeout,
53
+ client=http_client,
54
+ user_agent=user_agent or f"kitefrost-core/{CORE_VERSION}",
55
+ )
56
+
57
+ self.auth = AuthResource(self.transport)
58
+ self.keys = KeysResource(self.transport)
59
+ self.billing = BillingResource(self.transport)
60
+ self.projects = ProjectsResource(self.transport)
61
+ self.events = EventsResource(self.transport)
62
+ self.context = ContextResource(self.transport)
63
+ self.byok = ByokResource(self.transport)
64
+ self.webhooks = WebhooksResource(self.transport)
65
+ self.health = HealthResource(self.transport)
66
+
67
+ def close(self) -> None:
68
+ self.transport.close()
69
+
70
+ def __enter__(self) -> "KiteFrostCore":
71
+ return self
72
+
73
+ def __exit__(self, *_exc: object) -> None:
74
+ self.close()
@@ -0,0 +1,80 @@
1
+ """Typed exceptions for the KiteFrost shared core.
2
+
3
+ Mirrors the hand-written ``kitefrost`` SDK exception surface so per-pack SDKs
4
+ built on the core keep the same DX (KiteFrostError base + AuthError /
5
+ ProjectNotFound / ValidationError / RateLimited / ServerError, plus the
6
+ ``feedback_id`` field from C-FB1).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class KiteFrostError(Exception):
13
+ """Base exception for all KiteFrost core errors.
14
+
15
+ ``feedback_id`` (C-FB1) is a server-issued ULID (prefix ``fbk_``) that
16
+ uniquely identifies this error instance. Pass it back via
17
+ ``POST /v1/feedback`` to annotate or bug-report this specific failure.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ status_code: int | None = None,
24
+ feedback_id: str | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status_code = status_code
29
+ self.feedback_id = feedback_id
30
+
31
+ def __str__(self) -> str:
32
+ base = self.message
33
+ if self.feedback_id:
34
+ base = f"{base} [feedback_id={self.feedback_id}]"
35
+ return base
36
+
37
+ def __repr__(self) -> str:
38
+ cls = self.__class__.__name__
39
+ return (
40
+ f"{cls}(message={self.message!r}, status_code={self.status_code}, "
41
+ f"feedback_id={self.feedback_id!r})"
42
+ )
43
+
44
+
45
+ class AuthError(KiteFrostError):
46
+ """Raised when credentials are missing, invalid, or lack permission (401/403)."""
47
+
48
+
49
+ class NotFoundError(KiteFrostError):
50
+ """Raised when the requested resource does not exist (404)."""
51
+
52
+
53
+ class ValidationError(KiteFrostError):
54
+ """Raised when the API rejects the request payload (422)."""
55
+
56
+
57
+ class RateLimited(KiteFrostError):
58
+ """Raised when the API rate limit is exceeded (429).
59
+
60
+ Inspect ``retry_after`` for the suggested back-off (seconds).
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ message: str,
66
+ retry_after: int | None = None,
67
+ feedback_id: str | None = None,
68
+ ) -> None:
69
+ super().__init__(message, status_code=429, feedback_id=feedback_id)
70
+ self.retry_after = retry_after
71
+
72
+ def __repr__(self) -> str:
73
+ return (
74
+ f"RateLimited(message={self.message!r}, retry_after={self.retry_after}, "
75
+ f"feedback_id={self.feedback_id!r})"
76
+ )
77
+
78
+
79
+ class ServerError(KiteFrostError):
80
+ """Raised on unexpected 5xx responses from the KiteFrost API."""
@@ -0,0 +1,106 @@
1
+ """Shared-core resource clients.
2
+
3
+ These cover the SHARED_TAGS surface (src/engine/api/pack_manifest.py: auth,
4
+ keys, billing, projects, events, context, byok, webhooks, health, ...) that is
5
+ identical across every pack and is therefore owned by the core, NOT generated
6
+ per pack (PPI-3). Pack-specific resources are generated on top by STREAM-003
7
+ TASK-003. NO pack-specific code lives here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+ from urllib.parse import quote
14
+
15
+ from .transport import Transport
16
+
17
+
18
+ class _BaseResource:
19
+ def __init__(self, transport: Transport) -> None:
20
+ self._t = transport
21
+
22
+
23
+ class AuthResource(_BaseResource):
24
+ """auth tag - whoami / token introspection."""
25
+
26
+ def whoami(self) -> Any:
27
+ return self._t.get("/v1/auth/whoami")
28
+
29
+
30
+ class KeysResource(_BaseResource):
31
+ """keys tag - API key management."""
32
+
33
+ def list(self) -> Any:
34
+ return self._t.get("/v1/keys")
35
+
36
+ def create(self, body: dict[str, Any]) -> Any:
37
+ return self._t.post("/v1/keys", body)
38
+
39
+ def revoke(self, key_id: str) -> Any:
40
+ return self._t.delete(f"/v1/keys/{quote(key_id)}")
41
+
42
+
43
+ class BillingResource(_BaseResource):
44
+ """billing tag - usage + balance."""
45
+
46
+ def usage(self) -> Any:
47
+ return self._t.get("/v1/billing/usage")
48
+
49
+
50
+ class ProjectsResource(_BaseResource):
51
+ """projects tag - project lifecycle (shared across all packs)."""
52
+
53
+ def list(self) -> Any:
54
+ return self._t.get("/v1/projects")
55
+
56
+ def get(self, project_id: str) -> Any:
57
+ return self._t.get(f"/v1/projects/{quote(project_id)}")
58
+
59
+ def create(self, body: dict[str, Any]) -> Any:
60
+ return self._t.post("/v1/projects", body)
61
+
62
+
63
+ class EventsResource(_BaseResource):
64
+ """events tag - client/telemetry event ingestion."""
65
+
66
+ def send(self, body: dict[str, Any]) -> Any:
67
+ return self._t.post("/v1/events", body)
68
+
69
+
70
+ class ContextResource(_BaseResource):
71
+ """context tag - shared context retrieval."""
72
+
73
+ def get(self, project_id: str, params: dict[str, Any] | None = None) -> Any:
74
+ return self._t.get(f"/v1/projects/{quote(project_id)}/context", params=params)
75
+
76
+
77
+ class ByokResource(_BaseResource):
78
+ """byok tag - bring-your-own-key provider credentials."""
79
+
80
+ def list(self) -> Any:
81
+ return self._t.get("/v1/byok")
82
+
83
+ def set(self, body: dict[str, Any]) -> Any:
84
+ return self._t.post("/v1/byok", body)
85
+
86
+
87
+ class WebhooksResource(_BaseResource):
88
+ """webhooks tag - webhook subscription management."""
89
+
90
+ def list(self) -> Any:
91
+ return self._t.get("/v1/webhooks")
92
+
93
+ def create(self, body: dict[str, Any]) -> Any:
94
+ return self._t.post("/v1/webhooks", body)
95
+
96
+ def delete(self, webhook_id: str) -> Any:
97
+ return self._t.delete(f"/v1/webhooks/{quote(webhook_id)}")
98
+
99
+
100
+ class HealthResource(_BaseResource):
101
+ """health tag - liveness probe (the canonical core smoke endpoint)."""
102
+
103
+ def check(self) -> Any:
104
+ # Liveness probe is served at the root `/health` (the API does NOT
105
+ # expose `/v1/health` - that 404s). Found by tests/sdk_live.
106
+ return self._t.get("/health")
@@ -0,0 +1,136 @@
1
+ """Shared HTTP transport for the core.
2
+
3
+ Owns header building, error mapping, and the one-shot 401 refresh (mirrors the
4
+ hand-written ``kitefrost`` SDK ``_transport`` + ``http.ts``). A single instance
5
+ is shared across every per-pack resource client so they pool one auth provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from .auth import AuthProvider
15
+ from .exceptions import (
16
+ AuthError,
17
+ KiteFrostError,
18
+ NotFoundError,
19
+ RateLimited,
20
+ ServerError,
21
+ ValidationError,
22
+ )
23
+ from .version import CORE_VERSION
24
+
25
+ DEFAULT_BASE_URL = "https://api.kitefrost.ai"
26
+ DEFAULT_TIMEOUT = 30.0
27
+
28
+
29
+ def _raise_for_response(response: httpx.Response) -> None:
30
+ """Map an error response to a typed core exception."""
31
+ if response.is_success:
32
+ return
33
+
34
+ status = response.status_code
35
+ body: dict[str, Any] = {}
36
+ try:
37
+ parsed = response.json()
38
+ if isinstance(parsed, dict):
39
+ body = parsed
40
+ detail = body.get("detail") or body.get("error") or body.get("message") or response.text
41
+ except Exception:
42
+ detail = response.text
43
+
44
+ feedback_id = body.get("feedback_id") if isinstance(body, dict) else None
45
+
46
+ if status in (401, 403):
47
+ raise AuthError(detail, status_code=status, feedback_id=feedback_id)
48
+ if status == 404:
49
+ raise NotFoundError(detail, status_code=404, feedback_id=feedback_id)
50
+ if status == 422:
51
+ raise ValidationError(detail, status_code=422, feedback_id=feedback_id)
52
+ if status == 429:
53
+ retry_after: int | None = None
54
+ raw = response.headers.get("Retry-After")
55
+ if raw is not None:
56
+ try:
57
+ retry_after = int(raw)
58
+ except ValueError:
59
+ pass
60
+ raise RateLimited(detail, retry_after=retry_after, feedback_id=feedback_id)
61
+ if status >= 500:
62
+ raise ServerError(detail, status_code=status, feedback_id=feedback_id)
63
+ raise KiteFrostError(detail, status_code=status, feedback_id=feedback_id)
64
+
65
+
66
+ class Transport:
67
+ """Synchronous HTTP transport with one-shot 401 refresh."""
68
+
69
+ def __init__(
70
+ self,
71
+ auth: AuthProvider,
72
+ base_url: str = DEFAULT_BASE_URL,
73
+ timeout: float = DEFAULT_TIMEOUT,
74
+ client: httpx.Client | None = None,
75
+ user_agent: str | None = None,
76
+ ) -> None:
77
+ self._auth = auth
78
+ self._user_agent = user_agent or f"kitefrost-core/{CORE_VERSION}"
79
+ # A single httpx.Client = one connection pool shared by all resources.
80
+ self._client = client or httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout)
81
+
82
+ def _headers(self, auth_header: str) -> dict[str, str]:
83
+ return {
84
+ "Authorization": auth_header,
85
+ "Content-Type": "application/json",
86
+ "Accept": "application/json",
87
+ "User-Agent": self._user_agent,
88
+ }
89
+
90
+ def request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ *,
95
+ json: dict[str, Any] | None = None,
96
+ params: dict[str, Any] | None = None,
97
+ ) -> Any:
98
+ response = self._client.request(
99
+ method,
100
+ path,
101
+ json=json,
102
+ params=params,
103
+ headers=self._headers(self._auth.auth_header()),
104
+ )
105
+
106
+ if response.status_code == 401:
107
+ refreshed = self._auth.refresh()
108
+ if refreshed:
109
+ response = self._client.request(
110
+ method,
111
+ path,
112
+ json=json,
113
+ params=params,
114
+ headers=self._headers(refreshed),
115
+ )
116
+
117
+ _raise_for_response(response)
118
+
119
+ if response.status_code == 204 or not response.content:
120
+ return None
121
+ return response.json()
122
+
123
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
124
+ return self.request("GET", path, params=params)
125
+
126
+ def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
127
+ return self.request("POST", path, json=json or {})
128
+
129
+ def patch(self, path: str, json: dict[str, Any]) -> Any:
130
+ return self.request("PATCH", path, json=json)
131
+
132
+ def delete(self, path: str) -> Any:
133
+ return self.request("DELETE", path)
134
+
135
+ def close(self) -> None:
136
+ self._client.close()
@@ -0,0 +1,26 @@
1
+ """Lockstep version stamp for the shared core (D-PPI-SDK-BUNDLING / D-PPI-SDK-GENERATED).
2
+
3
+ Every per-pack SDK pins this exact value in lockstep with the backend contract
4
+ release. The per-pack codegen (STREAM-003 TASK-003) reads ``CORE_VERSION`` to
5
+ emit a compatible dependency range and to assert that the installed core matches
6
+ the contract version it was generated against.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ CORE_VERSION = "1.0.0"
12
+
13
+
14
+ def assert_core_version_compatible(required_major: int) -> None:
15
+ """Assert the installed core satisfies the major a per-pack SDK was built for.
16
+
17
+ Lockstep versioning forbids cross-major mixing (a fatal venv skew when two
18
+ packs pin incompatible cores); same-major is compatible.
19
+ """
20
+ installed_major = int(CORE_VERSION.split(".")[0])
21
+ if installed_major != required_major:
22
+ raise RuntimeError(
23
+ f"kitefrost-core version skew: installed {CORE_VERSION} "
24
+ f"(major {installed_major}) but a pack SDK requires core major {required_major}. "
25
+ "Per-pack SDKs and the core publish in lockstep; align their versions."
26
+ )
@@ -0,0 +1,103 @@
1
+ """Unit tests for the kitefrost-core shared runtime.
2
+
3
+ Uses httpx.MockTransport so no network is touched. Covers the smoke path
4
+ (auth + core endpoint), one-shot 401 refresh, static-key 401 surfacing, and
5
+ error mapping.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from kitefrost_core import (
14
+ ApiKeyAuth,
15
+ AuthError,
16
+ CORE_VERSION,
17
+ KiteFrostCore,
18
+ RateLimited,
19
+ RefreshableTokenAuth,
20
+ ValidationError,
21
+ assert_core_version_compatible,
22
+ )
23
+
24
+
25
+ def _make_core(handler, auth=None, api_key="sk_test"):
26
+ mock = httpx.MockTransport(handler)
27
+ client = httpx.Client(base_url="https://api.example", transport=mock)
28
+ if auth is not None:
29
+ return KiteFrostCore(auth=auth, http_client=client)
30
+ return KiteFrostCore(api_key=api_key, http_client=client)
31
+
32
+
33
+ def test_version_stamp_and_compat():
34
+ assert CORE_VERSION == "1.0.0"
35
+ assert_core_version_compatible(1)
36
+ with pytest.raises(RuntimeError, match="version skew"):
37
+ assert_core_version_compatible(2)
38
+
39
+
40
+ def test_requires_api_key_or_auth():
41
+ with pytest.raises(ValueError, match="requires either"):
42
+ KiteFrostCore()
43
+
44
+
45
+ def test_smoke_core_only_health_call():
46
+ seen = {}
47
+
48
+ def handler(request: httpx.Request) -> httpx.Response:
49
+ seen["url"] = str(request.url)
50
+ seen["auth"] = request.headers.get("Authorization")
51
+ return httpx.Response(200, json={"status": "ok"})
52
+
53
+ core = _make_core(handler)
54
+ assert core.health.check() == {"status": "ok"}
55
+ assert seen["url"] == "https://api.example/v1/health"
56
+ assert seen["auth"] == "Bearer sk_test"
57
+
58
+
59
+ def test_one_shot_401_refresh_retries():
60
+ calls = {"n": 0}
61
+ refreshes = {"n": 0}
62
+
63
+ def handler(request: httpx.Request) -> httpx.Response:
64
+ calls["n"] += 1
65
+ if request.headers.get("Authorization") == "Bearer stale":
66
+ return httpx.Response(401, json={"error": "expired"})
67
+ return httpx.Response(200, json={"ok": True})
68
+
69
+ def refresh_fn():
70
+ refreshes["n"] += 1
71
+ return "fresh"
72
+
73
+ auth = RefreshableTokenAuth("stale", refresh_fn)
74
+ core = _make_core(handler, auth=auth)
75
+ assert core.billing.usage() == {"ok": True}
76
+ assert calls["n"] == 2
77
+ assert refreshes["n"] == 1
78
+
79
+
80
+ def test_static_key_401_surfaces_auth_error():
81
+ def handler(request: httpx.Request) -> httpx.Response:
82
+ return httpx.Response(401, json={"error": "bad key"})
83
+
84
+ core = _make_core(handler, auth=ApiKeyAuth("nope"))
85
+ with pytest.raises(AuthError):
86
+ core.auth.whoami()
87
+
88
+
89
+ def test_error_mapping_422_and_429():
90
+ def handler_422(request: httpx.Request) -> httpx.Response:
91
+ return httpx.Response(422, json={"detail": "bad payload"})
92
+
93
+ core = _make_core(handler_422)
94
+ with pytest.raises(ValidationError):
95
+ core.projects.list()
96
+
97
+ def handler_429(request: httpx.Request) -> httpx.Response:
98
+ return httpx.Response(429, json={"error": "slow"}, headers={"Retry-After": "7"})
99
+
100
+ core = _make_core(handler_429)
101
+ with pytest.raises(RateLimited) as exc:
102
+ core.events.send({"kind": "x"})
103
+ assert exc.value.retry_after == 7