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.
- kitefrost_core-1.0.0/.gitignore +7 -0
- kitefrost_core-1.0.0/PKG-INFO +51 -0
- kitefrost_core-1.0.0/README.md +25 -0
- kitefrost_core-1.0.0/pyproject.toml +61 -0
- kitefrost_core-1.0.0/src/kitefrost_core/__init__.py +62 -0
- kitefrost_core-1.0.0/src/kitefrost_core/auth.py +59 -0
- kitefrost_core-1.0.0/src/kitefrost_core/core.py +74 -0
- kitefrost_core-1.0.0/src/kitefrost_core/exceptions.py +80 -0
- kitefrost_core-1.0.0/src/kitefrost_core/resources.py +106 -0
- kitefrost_core-1.0.0/src/kitefrost_core/transport.py +136 -0
- kitefrost_core-1.0.0/src/kitefrost_core/version.py +26 -0
- kitefrost_core-1.0.0/tests/test_core.py +103 -0
|
@@ -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
|