kainguru-sdk 0.1.4.dev0__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.
- kainguru/__init__.py +48 -0
- kainguru/_config.py +63 -0
- kainguru/_errors.py +81 -0
- kainguru/_polling.py +98 -0
- kainguru/_transport.py +206 -0
- kainguru/async_client.py +74 -0
- kainguru/client.py +74 -0
- kainguru/models.py +234 -0
- kainguru/py.typed +0 -0
- kainguru/resources/__init__.py +9 -0
- kainguru/resources/_common.py +35 -0
- kainguru/resources/executions.py +123 -0
- kainguru/resources/finetuning.py +121 -0
- kainguru_sdk-0.1.4.dev0.dist-info/METADATA +251 -0
- kainguru_sdk-0.1.4.dev0.dist-info/RECORD +16 -0
- kainguru_sdk-0.1.4.dev0.dist-info/WHEEL +4 -0
kainguru/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Kainguru Python SDK — run models and fine-tune them on the Kainguru ML platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._config import KainguruConfig
|
|
6
|
+
from ._errors import (
|
|
7
|
+
KainguruAPIError,
|
|
8
|
+
KainguruAuthError,
|
|
9
|
+
KainguruConfigError,
|
|
10
|
+
KainguruConnectionError,
|
|
11
|
+
KainguruError,
|
|
12
|
+
KainguruNotFoundError,
|
|
13
|
+
KainguruRateLimitError,
|
|
14
|
+
KainguruTimeoutError,
|
|
15
|
+
)
|
|
16
|
+
from .async_client import AsyncKainguruClient
|
|
17
|
+
from .client import KainguruClient
|
|
18
|
+
from .models import (
|
|
19
|
+
ApplicationDto,
|
|
20
|
+
ExecutionResultDto,
|
|
21
|
+
ModelExecResponseDto,
|
|
22
|
+
ModelFineTunedDetailDto,
|
|
23
|
+
ModelStatus,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"KainguruClient",
|
|
31
|
+
"AsyncKainguruClient",
|
|
32
|
+
"KainguruConfig",
|
|
33
|
+
# Errors
|
|
34
|
+
"KainguruError",
|
|
35
|
+
"KainguruConfigError",
|
|
36
|
+
"KainguruAPIError",
|
|
37
|
+
"KainguruAuthError",
|
|
38
|
+
"KainguruNotFoundError",
|
|
39
|
+
"KainguruRateLimitError",
|
|
40
|
+
"KainguruTimeoutError",
|
|
41
|
+
"KainguruConnectionError",
|
|
42
|
+
# Models
|
|
43
|
+
"ModelStatus",
|
|
44
|
+
"ModelExecResponseDto",
|
|
45
|
+
"ModelFineTunedDetailDto",
|
|
46
|
+
"ApplicationDto",
|
|
47
|
+
"ExecutionResultDto",
|
|
48
|
+
]
|
kainguru/_config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Client configuration resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ._errors import KainguruConfigError
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT = 30.0
|
|
10
|
+
DEFAULT_MAX_RETRIES = 3
|
|
11
|
+
DEFAULT_RETRY_BASE_DELAY = 1.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class KainguruConfig:
|
|
16
|
+
api_key: str
|
|
17
|
+
base_url: str
|
|
18
|
+
timeout: float
|
|
19
|
+
max_retries: int
|
|
20
|
+
retry_base_delay: float
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def resolve(
|
|
24
|
+
cls,
|
|
25
|
+
*,
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
base_url: str | None = None,
|
|
28
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
29
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
30
|
+
retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
|
|
31
|
+
) -> KainguruConfig:
|
|
32
|
+
"""Resolve configuration, failing fast when the caller omits a required field.
|
|
33
|
+
|
|
34
|
+
Both ``api_key`` and ``base_url`` are required and must be passed
|
|
35
|
+
explicitly. (A per-request ``api_key=`` kwarg may still override per call.)
|
|
36
|
+
"""
|
|
37
|
+
if not api_key or not api_key.strip():
|
|
38
|
+
raise KainguruConfigError(
|
|
39
|
+
"Kainguru API key is required. Pass api_key=... to the client."
|
|
40
|
+
)
|
|
41
|
+
if not base_url or not base_url.strip():
|
|
42
|
+
raise KainguruConfigError(
|
|
43
|
+
"Kainguru base URL is required. Pass base_url=... (the full host "
|
|
44
|
+
"including the /api context path)."
|
|
45
|
+
)
|
|
46
|
+
if timeout <= 0:
|
|
47
|
+
raise KainguruConfigError("timeout must be > 0")
|
|
48
|
+
if max_retries < 0:
|
|
49
|
+
raise KainguruConfigError("max_retries must be >= 0")
|
|
50
|
+
|
|
51
|
+
return cls(
|
|
52
|
+
api_key=api_key.strip(),
|
|
53
|
+
# httpx resolves request paths against base_url using RFC 3986, so the
|
|
54
|
+
# base URL MUST end with "/" or the "/api" context path is dropped.
|
|
55
|
+
base_url=_ensure_trailing_slash(base_url.strip()),
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
max_retries=max_retries,
|
|
58
|
+
retry_base_delay=retry_base_delay,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ensure_trailing_slash(url: str) -> str:
|
|
63
|
+
return url if url.endswith("/") else url + "/"
|
kainguru/_errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Exception hierarchy for the Kainguru SDK.
|
|
2
|
+
|
|
3
|
+
Every error extends :class:`KainguruError`. HTTP-derived errors expose the status
|
|
4
|
+
code, raw body, optional API code, and the underlying ``httpx.Response`` when
|
|
5
|
+
available. Specific subclasses for common statuses let callers narrow their
|
|
6
|
+
``except`` clauses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KainguruError(Exception):
|
|
15
|
+
"""Base class for all SDK errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KainguruConfigError(KainguruError):
|
|
19
|
+
"""Missing or invalid configuration (e.g. no API key, bad base URL)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KainguruAPIError(KainguruError):
|
|
23
|
+
"""Non-2xx HTTP response, or a 2xx body with ``success == False``."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
status_code: int,
|
|
30
|
+
body: str = "",
|
|
31
|
+
api_code: int | None = None,
|
|
32
|
+
response: Any | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.body = body
|
|
37
|
+
self.api_code = api_code
|
|
38
|
+
self.response = response
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KainguruAuthError(KainguruAPIError):
|
|
42
|
+
"""401 / 403 — authentication or authorization failure."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KainguruNotFoundError(KainguruAPIError):
|
|
46
|
+
"""404 — resource not found."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KainguruRateLimitError(KainguruAPIError):
|
|
50
|
+
"""429 — rate limited. ``retry_after`` (seconds) is set when the header is present."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str,
|
|
55
|
+
*,
|
|
56
|
+
status_code: int,
|
|
57
|
+
body: str = "",
|
|
58
|
+
api_code: int | None = None,
|
|
59
|
+
response: Any | None = None,
|
|
60
|
+
retry_after: float | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(
|
|
63
|
+
message,
|
|
64
|
+
status_code=status_code,
|
|
65
|
+
body=body,
|
|
66
|
+
api_code=api_code,
|
|
67
|
+
response=response,
|
|
68
|
+
)
|
|
69
|
+
self.retry_after = retry_after
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class KainguruTimeoutError(KainguruError):
|
|
73
|
+
"""``await_completion`` exceeded its timeout. Carries the last DTO observed."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str, *, last_dto: Any | None = None) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.last_dto = last_dto
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class KainguruConnectionError(KainguruError):
|
|
81
|
+
"""Network / transport failure. The underlying error is chained via ``from``."""
|
kainguru/_polling.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Polling engines for ``await_completion`` — sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from ._errors import KainguruTimeoutError
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
15
|
+
DEFAULT_TIMEOUT = 300.0
|
|
16
|
+
DEFAULT_BACKOFF = 1.0
|
|
17
|
+
DEFAULT_MAX_INTERVAL = 30.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate(poll_interval: float, timeout: float, backoff: float, max_interval: float) -> None:
|
|
21
|
+
if poll_interval <= 0:
|
|
22
|
+
raise ValueError("poll_interval must be > 0")
|
|
23
|
+
if timeout <= 0:
|
|
24
|
+
raise ValueError("timeout must be > 0")
|
|
25
|
+
if backoff < 1.0:
|
|
26
|
+
raise ValueError("backoff must be >= 1.0")
|
|
27
|
+
if max_interval <= 0:
|
|
28
|
+
raise ValueError("max_interval must be > 0")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def await_completion(
|
|
32
|
+
get_fn: Callable[[], T],
|
|
33
|
+
*,
|
|
34
|
+
is_terminal: Callable[[T], bool],
|
|
35
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
36
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
37
|
+
backoff: float = DEFAULT_BACKOFF,
|
|
38
|
+
max_interval: float = DEFAULT_MAX_INTERVAL,
|
|
39
|
+
_clock: Callable[[], float] = time.monotonic,
|
|
40
|
+
_sleep: Callable[[float], None] = time.sleep,
|
|
41
|
+
) -> T:
|
|
42
|
+
"""Poll ``get_fn`` until ``is_terminal`` is true, then return that value.
|
|
43
|
+
|
|
44
|
+
Raises :class:`KainguruTimeoutError` (carrying the last DTO) on timeout. A
|
|
45
|
+
``FAILED`` status is terminal and therefore *returns* — inspect the result.
|
|
46
|
+
"""
|
|
47
|
+
_validate(poll_interval, timeout, backoff, max_interval)
|
|
48
|
+
start = _clock()
|
|
49
|
+
deadline = start + timeout
|
|
50
|
+
interval = poll_interval
|
|
51
|
+
last: T | None = None
|
|
52
|
+
|
|
53
|
+
while True:
|
|
54
|
+
last = get_fn()
|
|
55
|
+
if is_terminal(last):
|
|
56
|
+
return last
|
|
57
|
+
now = _clock()
|
|
58
|
+
if now >= deadline:
|
|
59
|
+
raise KainguruTimeoutError(
|
|
60
|
+
f"Polling timed out after {timeout}s", last_dto=last
|
|
61
|
+
)
|
|
62
|
+
sleep_for = min(interval, max_interval, deadline - now)
|
|
63
|
+
if sleep_for > 0:
|
|
64
|
+
_sleep(sleep_for)
|
|
65
|
+
interval = min(interval * backoff, max_interval)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def await_completion_async(
|
|
69
|
+
get_fn: Callable[[], Awaitable[T]],
|
|
70
|
+
*,
|
|
71
|
+
is_terminal: Callable[[T], bool],
|
|
72
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
73
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
74
|
+
backoff: float = DEFAULT_BACKOFF,
|
|
75
|
+
max_interval: float = DEFAULT_MAX_INTERVAL,
|
|
76
|
+
_clock: Callable[[], float] = time.monotonic,
|
|
77
|
+
_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
78
|
+
) -> T:
|
|
79
|
+
"""Async mirror of :func:`await_completion` using non-blocking sleeps."""
|
|
80
|
+
_validate(poll_interval, timeout, backoff, max_interval)
|
|
81
|
+
start = _clock()
|
|
82
|
+
deadline = start + timeout
|
|
83
|
+
interval = poll_interval
|
|
84
|
+
last: T | None = None
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
last = await get_fn()
|
|
88
|
+
if is_terminal(last):
|
|
89
|
+
return last
|
|
90
|
+
now = _clock()
|
|
91
|
+
if now >= deadline:
|
|
92
|
+
raise KainguruTimeoutError(
|
|
93
|
+
f"Polling timed out after {timeout}s", last_dto=last
|
|
94
|
+
)
|
|
95
|
+
sleep_for = min(interval, max_interval, deadline - now)
|
|
96
|
+
if sleep_for > 0:
|
|
97
|
+
await _sleep(sleep_for)
|
|
98
|
+
interval = min(interval * backoff, max_interval)
|
kainguru/_transport.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""HTTP transport: auth header injection, retry, error mapping, envelope unwrap.
|
|
2
|
+
|
|
3
|
+
Two thin wrappers — :class:`Transport` (sync) and :class:`AsyncTransport`
|
|
4
|
+
(async) — share the retry/error helpers below so the two clients behave
|
|
5
|
+
identically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from pydantic import BaseModel, ValidationError
|
|
17
|
+
|
|
18
|
+
from ._config import KainguruConfig
|
|
19
|
+
from ._errors import (
|
|
20
|
+
KainguruAPIError,
|
|
21
|
+
KainguruAuthError,
|
|
22
|
+
KainguruConnectionError,
|
|
23
|
+
KainguruNotFoundError,
|
|
24
|
+
KainguruRateLimitError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
API_KEY_HEADER = "X-API-Key"
|
|
28
|
+
|
|
29
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _headers(config: KainguruConfig, api_key: str | None) -> dict[str, str]:
|
|
33
|
+
headers = {"Accept": "application/json"}
|
|
34
|
+
key = api_key or config.api_key
|
|
35
|
+
if key:
|
|
36
|
+
headers[API_KEY_HEADER] = key
|
|
37
|
+
return headers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_retriable_status(status_code: int) -> bool:
|
|
41
|
+
return status_code == 429 or 500 <= status_code <= 599
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _retry_after_seconds(response: httpx.Response) -> float | None:
|
|
45
|
+
raw = response.headers.get("retry-after")
|
|
46
|
+
if not raw:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return max(0.0, float(int(raw.strip())))
|
|
50
|
+
except (ValueError, TypeError):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _retry_delay(attempt: int, response: httpx.Response | None, base_delay: float) -> float:
|
|
55
|
+
if response is not None:
|
|
56
|
+
retry_after = _retry_after_seconds(response)
|
|
57
|
+
if retry_after is not None:
|
|
58
|
+
return retry_after
|
|
59
|
+
exp = base_delay * (2**attempt)
|
|
60
|
+
jitter = random.uniform(0, max(0.001, exp / 2))
|
|
61
|
+
return float(exp + jitter)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _map_http_error(response: httpx.Response) -> KainguruAPIError:
|
|
65
|
+
status = response.status_code
|
|
66
|
+
body = response.text
|
|
67
|
+
message = f"Kainguru API error: HTTP {status}"
|
|
68
|
+
if status in (401, 403):
|
|
69
|
+
return KainguruAuthError(message, status_code=status, body=body, response=response)
|
|
70
|
+
if status == 404:
|
|
71
|
+
return KainguruNotFoundError(message, status_code=status, body=body, response=response)
|
|
72
|
+
if status == 429:
|
|
73
|
+
return KainguruRateLimitError(
|
|
74
|
+
message,
|
|
75
|
+
status_code=status,
|
|
76
|
+
body=body,
|
|
77
|
+
response=response,
|
|
78
|
+
retry_after=_retry_after_seconds(response),
|
|
79
|
+
)
|
|
80
|
+
return KainguruAPIError(message, status_code=status, body=body, response=response)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def unwrap(response: httpx.Response, model_cls: type[ModelT]) -> ModelT:
|
|
84
|
+
"""Unwrap a ``CommonApiResponse`` envelope into ``model_cls``.
|
|
85
|
+
|
|
86
|
+
Raises :class:`KainguruAPIError` on non-2xx, a malformed body, or
|
|
87
|
+
``success == False``.
|
|
88
|
+
"""
|
|
89
|
+
if not response.is_success:
|
|
90
|
+
raise _map_http_error(response)
|
|
91
|
+
try:
|
|
92
|
+
body = response.json()
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise KainguruAPIError(
|
|
95
|
+
"Malformed JSON in Kainguru API response",
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
body=response.text,
|
|
98
|
+
response=response,
|
|
99
|
+
) from exc
|
|
100
|
+
if not isinstance(body, dict) or "success" not in body:
|
|
101
|
+
raise KainguruAPIError(
|
|
102
|
+
"Unexpected response body (missing CommonApiResponse envelope)",
|
|
103
|
+
status_code=response.status_code,
|
|
104
|
+
body=response.text,
|
|
105
|
+
response=response,
|
|
106
|
+
)
|
|
107
|
+
if not body.get("success"):
|
|
108
|
+
raise KainguruAPIError(
|
|
109
|
+
f"Kainguru API returned success=false (code={body.get('code')})",
|
|
110
|
+
status_code=response.status_code,
|
|
111
|
+
body=response.text,
|
|
112
|
+
api_code=body.get("code"),
|
|
113
|
+
response=response,
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
return model_cls.model_validate(body.get("data"))
|
|
117
|
+
except ValidationError as exc:
|
|
118
|
+
raise KainguruAPIError(
|
|
119
|
+
f"Failed to parse Kainguru API response into {model_cls.__name__}",
|
|
120
|
+
status_code=response.status_code,
|
|
121
|
+
body=response.text,
|
|
122
|
+
response=response,
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Transport:
|
|
127
|
+
"""Synchronous transport wrapping an ``httpx.Client``."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, client: httpx.Client, config: KainguruConfig) -> None:
|
|
130
|
+
self._client = client
|
|
131
|
+
self._config = config
|
|
132
|
+
|
|
133
|
+
def request(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
url: str,
|
|
137
|
+
*,
|
|
138
|
+
params: dict[str, Any] | None = None,
|
|
139
|
+
json: dict[str, Any] | None = None,
|
|
140
|
+
api_key: str | None = None,
|
|
141
|
+
) -> httpx.Response:
|
|
142
|
+
headers = _headers(self._config, api_key)
|
|
143
|
+
last_exc: Exception | None = None
|
|
144
|
+
for attempt in range(self._config.max_retries + 1):
|
|
145
|
+
try:
|
|
146
|
+
response = self._client.request(
|
|
147
|
+
method, url, params=params, json=json, headers=headers
|
|
148
|
+
)
|
|
149
|
+
except httpx.RequestError as exc:
|
|
150
|
+
last_exc = exc
|
|
151
|
+
if attempt >= self._config.max_retries:
|
|
152
|
+
raise KainguruConnectionError(
|
|
153
|
+
f"Network error while calling the Kainguru API: {exc}"
|
|
154
|
+
) from exc
|
|
155
|
+
time.sleep(_retry_delay(attempt, None, self._config.retry_base_delay))
|
|
156
|
+
continue
|
|
157
|
+
if _is_retriable_status(response.status_code) and attempt < self._config.max_retries:
|
|
158
|
+
time.sleep(_retry_delay(attempt, response, self._config.retry_base_delay))
|
|
159
|
+
continue
|
|
160
|
+
return response
|
|
161
|
+
# Unreachable, but keeps type checkers happy.
|
|
162
|
+
raise KainguruConnectionError("Retry loop exhausted") from last_exc
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
self._client.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AsyncTransport:
|
|
169
|
+
"""Asynchronous transport wrapping an ``httpx.AsyncClient``."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, client: httpx.AsyncClient, config: KainguruConfig) -> None:
|
|
172
|
+
self._client = client
|
|
173
|
+
self._config = config
|
|
174
|
+
|
|
175
|
+
async def request(
|
|
176
|
+
self,
|
|
177
|
+
method: str,
|
|
178
|
+
url: str,
|
|
179
|
+
*,
|
|
180
|
+
params: dict[str, Any] | None = None,
|
|
181
|
+
json: dict[str, Any] | None = None,
|
|
182
|
+
api_key: str | None = None,
|
|
183
|
+
) -> httpx.Response:
|
|
184
|
+
headers = _headers(self._config, api_key)
|
|
185
|
+
last_exc: Exception | None = None
|
|
186
|
+
for attempt in range(self._config.max_retries + 1):
|
|
187
|
+
try:
|
|
188
|
+
response = await self._client.request(
|
|
189
|
+
method, url, params=params, json=json, headers=headers
|
|
190
|
+
)
|
|
191
|
+
except httpx.RequestError as exc:
|
|
192
|
+
last_exc = exc
|
|
193
|
+
if attempt >= self._config.max_retries:
|
|
194
|
+
raise KainguruConnectionError(
|
|
195
|
+
f"Network error while calling the Kainguru API: {exc}"
|
|
196
|
+
) from exc
|
|
197
|
+
await asyncio.sleep(_retry_delay(attempt, None, self._config.retry_base_delay))
|
|
198
|
+
continue
|
|
199
|
+
if _is_retriable_status(response.status_code) and attempt < self._config.max_retries:
|
|
200
|
+
await asyncio.sleep(_retry_delay(attempt, response, self._config.retry_base_delay))
|
|
201
|
+
continue
|
|
202
|
+
return response
|
|
203
|
+
raise KainguruConnectionError("Retry loop exhausted") from last_exc
|
|
204
|
+
|
|
205
|
+
async def aclose(self) -> None:
|
|
206
|
+
await self._client.aclose()
|
kainguru/async_client.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Asynchronous client facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._config import (
|
|
10
|
+
DEFAULT_MAX_RETRIES,
|
|
11
|
+
DEFAULT_RETRY_BASE_DELAY,
|
|
12
|
+
DEFAULT_TIMEOUT,
|
|
13
|
+
KainguruConfig,
|
|
14
|
+
)
|
|
15
|
+
from ._transport import AsyncTransport
|
|
16
|
+
from .resources import AsyncExecutionsResource, AsyncFineTuningResource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncKainguruClient:
|
|
20
|
+
"""Entry point for the Kainguru SDK (async).
|
|
21
|
+
|
|
22
|
+
Example::
|
|
23
|
+
|
|
24
|
+
async with AsyncKainguruClient(
|
|
25
|
+
api_key="kg_...", base_url="https://your-host/api"
|
|
26
|
+
) as client:
|
|
27
|
+
submitted = await client.executions.execute(
|
|
28
|
+
"my-mlflow-id", {"prompt": "hello world"}, "json"
|
|
29
|
+
)
|
|
30
|
+
done = await client.executions.await_completion(submitted.id)
|
|
31
|
+
print(done.status)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str,
|
|
39
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
40
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
41
|
+
retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
|
|
42
|
+
http_client: httpx.AsyncClient | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._config = KainguruConfig.resolve(
|
|
45
|
+
api_key=api_key,
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
max_retries=max_retries,
|
|
49
|
+
retry_base_delay=retry_base_delay,
|
|
50
|
+
)
|
|
51
|
+
client = http_client or httpx.AsyncClient(
|
|
52
|
+
base_url=self._config.base_url, timeout=self._config.timeout
|
|
53
|
+
)
|
|
54
|
+
self._transport = AsyncTransport(client, self._config)
|
|
55
|
+
self.executions = AsyncExecutionsResource(self._transport)
|
|
56
|
+
self.fine_tuning = AsyncFineTuningResource(self._transport)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def config(self) -> KainguruConfig:
|
|
60
|
+
return self._config
|
|
61
|
+
|
|
62
|
+
async def aclose(self) -> None:
|
|
63
|
+
await self._transport.aclose()
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> AsyncKainguruClient:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(
|
|
69
|
+
self,
|
|
70
|
+
exc_type: type[BaseException] | None,
|
|
71
|
+
exc: BaseException | None,
|
|
72
|
+
tb: TracebackType | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
await self.aclose()
|
kainguru/client.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Synchronous client facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._config import (
|
|
10
|
+
DEFAULT_MAX_RETRIES,
|
|
11
|
+
DEFAULT_RETRY_BASE_DELAY,
|
|
12
|
+
DEFAULT_TIMEOUT,
|
|
13
|
+
KainguruConfig,
|
|
14
|
+
)
|
|
15
|
+
from ._transport import Transport
|
|
16
|
+
from .resources import ExecutionsResource, FineTuningResource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KainguruClient:
|
|
20
|
+
"""Entry point for the Kainguru SDK (sync).
|
|
21
|
+
|
|
22
|
+
Example::
|
|
23
|
+
|
|
24
|
+
with KainguruClient(
|
|
25
|
+
api_key="kg_...", base_url="https://your-host/api"
|
|
26
|
+
) as client:
|
|
27
|
+
submitted = client.executions.execute(
|
|
28
|
+
"my-mlflow-id", {"prompt": "hello world"}, "json"
|
|
29
|
+
)
|
|
30
|
+
done = client.executions.await_completion(submitted.id)
|
|
31
|
+
print(done.status) # ModelStatus.COMPLETED / FAILED
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str,
|
|
39
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
40
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
41
|
+
retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
|
|
42
|
+
http_client: httpx.Client | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._config = KainguruConfig.resolve(
|
|
45
|
+
api_key=api_key,
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
max_retries=max_retries,
|
|
49
|
+
retry_base_delay=retry_base_delay,
|
|
50
|
+
)
|
|
51
|
+
client = http_client or httpx.Client(
|
|
52
|
+
base_url=self._config.base_url, timeout=self._config.timeout
|
|
53
|
+
)
|
|
54
|
+
self._transport = Transport(client, self._config)
|
|
55
|
+
self.executions = ExecutionsResource(self._transport)
|
|
56
|
+
self.fine_tuning = FineTuningResource(self._transport)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def config(self) -> KainguruConfig:
|
|
60
|
+
return self._config
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
self._transport.close()
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> KainguruClient:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(
|
|
69
|
+
self,
|
|
70
|
+
exc_type: type[BaseException] | None,
|
|
71
|
+
exc: BaseException | None,
|
|
72
|
+
tb: TracebackType | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.close()
|