spt-models 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
spt_models/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """SPT Models Python client library."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from ._client import Client, AsyncClient
6
+ from ._exceptions import (
7
+ SPTError,
8
+ AuthenticationError,
9
+ RateLimitError,
10
+ NotFoundError,
11
+ ValidationError,
12
+ UpstreamError,
13
+ APIConnectionError,
14
+ APIStatusError,
15
+ )
16
+ from .types.chat import ChatCompletion, ChatCompletionChunk, ChatMessage
17
+ from .types.completions import Completion, CompletionChunk
18
+ from .types.embeddings import EmbeddingResponse
19
+ from .types.images import ImageResponse, VideoResponse
20
+ from .types.audio import Transcription
21
+ from .types.models import Model, ModelList, ModelParameters, ParameterInfo
22
+
23
+ __all__ = [
24
+ # Clients
25
+ "Client",
26
+ "AsyncClient",
27
+ # Exceptions
28
+ "SPTError",
29
+ "AuthenticationError",
30
+ "RateLimitError",
31
+ "NotFoundError",
32
+ "ValidationError",
33
+ "UpstreamError",
34
+ "APIConnectionError",
35
+ "APIStatusError",
36
+ # Inference types
37
+ "ChatCompletion",
38
+ "ChatCompletionChunk",
39
+ "ChatMessage",
40
+ "Completion",
41
+ "CompletionChunk",
42
+ "EmbeddingResponse",
43
+ "ImageResponse",
44
+ "VideoResponse",
45
+ "Transcription",
46
+ "Model",
47
+ "ModelList",
48
+ "ModelParameters",
49
+ "ParameterInfo",
50
+ # Version
51
+ "__version__",
52
+ ]
spt_models/_base.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shared base client configuration and utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ _DEFAULT_BASE_URL = "https://models.sponge-theory.dev"
9
+ # Server-side inference budget is 3600s end-to-end (orchestrator's
10
+ # inference_timeout_seconds, gateway's proxy/admin-router timeouts — video
11
+ # gen can run 28+ min). Default must clear that budget with margin, or the
12
+ # client gives up on a call the server is about to finish successfully — the
13
+ # worst kind of failure, since it looks like an error when none occurred.
14
+ _DEFAULT_TIMEOUT = 3900 # seconds
15
+
16
+
17
+ @dataclass
18
+ class BaseClientConfig:
19
+ api_key: str | None = None
20
+ admin_token: str | None = None
21
+ base_url: str = ""
22
+ timeout: float | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ if not self.api_key:
26
+ self.api_key = os.environ.get("SPT_API_KEY")
27
+ if not self.admin_token:
28
+ self.admin_token = os.environ.get("SPT_ADMIN_TOKEN")
29
+ if not self.base_url:
30
+ self.base_url = os.environ.get("SPT_BASE_URL", _DEFAULT_BASE_URL)
31
+ if self.base_url.endswith("/"):
32
+ self.base_url = self.base_url.rstrip("/")
33
+ if self.timeout is None:
34
+ self.timeout = _DEFAULT_TIMEOUT
35
+
36
+
37
+ def build_headers(*, api_key: str | None) -> dict[str, str]:
38
+ """Build HTTP headers for inference requests (API key only)."""
39
+ headers: dict[str, str] = {"Accept": "application/json"}
40
+ if api_key:
41
+ headers["Authorization"] = f"Bearer {api_key}"
42
+ return headers
43
+
44
+
45
+ def build_admin_headers(*, admin_token: str | None) -> dict[str, str]:
46
+ """Build HTTP headers for admin requests (admin token only)."""
47
+ headers: dict[str, str] = {"Accept": "application/json"}
48
+ if admin_token:
49
+ headers["Authorization"] = f"Bearer {admin_token}"
50
+ return headers
spt_models/_client.py ADDED
@@ -0,0 +1,238 @@
1
+ """Sync and async client implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ._base import BaseClientConfig, build_headers, build_admin_headers
10
+ from ._exceptions import APIConnectionError, raise_for_status
11
+
12
+
13
+ class _ResourceMixin:
14
+ """Provides lazy resource namespace access shared between sync/async clients."""
15
+
16
+ _config: BaseClientConfig
17
+
18
+ @property
19
+ def chat(self) -> Any:
20
+ if not hasattr(self, "_chat"):
21
+ from .resources.chat import ChatResource
22
+ self._chat = ChatResource(self)
23
+ return self._chat
24
+
25
+ @property
26
+ def completions(self) -> Any:
27
+ if not hasattr(self, "_completions"):
28
+ from .resources.completions import CompletionsResource
29
+ self._completions = CompletionsResource(self)
30
+ return self._completions
31
+
32
+ @property
33
+ def embeddings(self) -> Any:
34
+ if not hasattr(self, "_embeddings"):
35
+ from .resources.embeddings import EmbeddingsResource
36
+ self._embeddings = EmbeddingsResource(self)
37
+ return self._embeddings
38
+
39
+ @property
40
+ def images(self) -> Any:
41
+ if not hasattr(self, "_images"):
42
+ from .resources.images import ImagesResource
43
+ self._images = ImagesResource(self)
44
+ return self._images
45
+
46
+ @property
47
+ def videos(self) -> Any:
48
+ if not hasattr(self, "_videos"):
49
+ from .resources.videos import VideosResource
50
+ self._videos = VideosResource(self)
51
+ return self._videos
52
+
53
+ @property
54
+ def audio(self) -> Any:
55
+ if not hasattr(self, "_audio"):
56
+ from .resources.audio import AudioResource
57
+ self._audio = AudioResource(self)
58
+ return self._audio
59
+
60
+ @property
61
+ def models(self) -> Any:
62
+ if not hasattr(self, "_models"):
63
+ from .resources.models import ModelsResource
64
+ self._models = ModelsResource(self)
65
+ return self._models
66
+
67
+ @property
68
+ def admin(self) -> Any:
69
+ if not hasattr(self, "_admin"):
70
+ from .resources.admin import AdminResource
71
+ self._admin = AdminResource(self)
72
+ return self._admin
73
+
74
+
75
+ class Client(_ResourceMixin):
76
+ """Synchronous SPT Models client."""
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ api_key: str | None = None,
82
+ admin_token: str | None = None,
83
+ base_url: str = "",
84
+ timeout: float | None = None,
85
+ ) -> None:
86
+ self._config = BaseClientConfig(
87
+ api_key=api_key, admin_token=admin_token,
88
+ base_url=base_url, timeout=timeout,
89
+ )
90
+ self._http = httpx.Client(
91
+ base_url=self._config.base_url,
92
+ timeout=httpx.Timeout(self._config.timeout, connect=10.0),
93
+ )
94
+
95
+ def _request(
96
+ self,
97
+ method: str,
98
+ path: str,
99
+ *,
100
+ json: dict[str, Any] | None = None,
101
+ params: dict[str, Any] | None = None,
102
+ headers: dict[str, str] | None = None,
103
+ timeout: float | None = None,
104
+ admin: bool = False,
105
+ ) -> httpx.Response:
106
+ """Send an HTTP request with auth headers and error handling."""
107
+ if headers is None:
108
+ if admin:
109
+ headers = build_admin_headers(admin_token=self._config.admin_token)
110
+ else:
111
+ headers = build_headers(api_key=self._config.api_key)
112
+ try:
113
+ resp = self._http.request(
114
+ method, path, json=json, params=params,
115
+ headers=headers, timeout=timeout,
116
+ )
117
+ except httpx.ConnectError as e:
118
+ raise APIConnectionError(f"Connection error: {e}") from e
119
+ except httpx.TimeoutException as e:
120
+ raise APIConnectionError(f"Request timed out: {e}") from e
121
+ raise_for_status(resp)
122
+ return resp
123
+
124
+ def _stream_request(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ *,
129
+ json: dict[str, Any] | None = None,
130
+ headers: dict[str, str] | None = None,
131
+ timeout: float | None = None,
132
+ ) -> httpx.Response:
133
+ """Send a request and return the response for SSE line parsing."""
134
+ if headers is None:
135
+ headers = build_headers(api_key=self._config.api_key)
136
+ try:
137
+ resp = self._http.request(
138
+ method, path, json=json, headers=headers, timeout=timeout,
139
+ )
140
+ except httpx.ConnectError as e:
141
+ raise APIConnectionError(f"Connection error: {e}") from e
142
+ except httpx.TimeoutException as e:
143
+ raise APIConnectionError(f"Request timed out: {e}") from e
144
+ raise_for_status(resp)
145
+ return resp
146
+
147
+ def close(self) -> None:
148
+ """Close the underlying HTTP client."""
149
+ self._http.close()
150
+
151
+ def __enter__(self) -> Client:
152
+ return self
153
+
154
+ def __exit__(self, *args: Any) -> None:
155
+ self.close()
156
+
157
+
158
+ class AsyncClient(_ResourceMixin):
159
+ """Asynchronous SPT Models client."""
160
+
161
+ def __init__(
162
+ self,
163
+ *,
164
+ api_key: str | None = None,
165
+ admin_token: str | None = None,
166
+ base_url: str = "",
167
+ timeout: float | None = None,
168
+ ) -> None:
169
+ self._config = BaseClientConfig(
170
+ api_key=api_key, admin_token=admin_token,
171
+ base_url=base_url, timeout=timeout,
172
+ )
173
+ self._http = httpx.AsyncClient(
174
+ base_url=self._config.base_url,
175
+ timeout=httpx.Timeout(self._config.timeout, connect=10.0),
176
+ )
177
+
178
+ async def _request(
179
+ self,
180
+ method: str,
181
+ path: str,
182
+ *,
183
+ json: dict[str, Any] | None = None,
184
+ params: dict[str, Any] | None = None,
185
+ headers: dict[str, str] | None = None,
186
+ timeout: float | None = None,
187
+ admin: bool = False,
188
+ ) -> httpx.Response:
189
+ """Send an async HTTP request with auth headers and error handling."""
190
+ if headers is None:
191
+ if admin:
192
+ headers = build_admin_headers(admin_token=self._config.admin_token)
193
+ else:
194
+ headers = build_headers(api_key=self._config.api_key)
195
+ try:
196
+ resp = await self._http.request(
197
+ method, path, json=json, params=params,
198
+ headers=headers, timeout=timeout,
199
+ )
200
+ except httpx.ConnectError as e:
201
+ raise APIConnectionError(f"Connection error: {e}") from e
202
+ except httpx.TimeoutException as e:
203
+ raise APIConnectionError(f"Request timed out: {e}") from e
204
+ raise_for_status(resp)
205
+ return resp
206
+
207
+ async def _stream_request(
208
+ self,
209
+ method: str,
210
+ path: str,
211
+ *,
212
+ json: dict[str, Any] | None = None,
213
+ headers: dict[str, str] | None = None,
214
+ timeout: float | None = None,
215
+ ) -> httpx.Response:
216
+ """Send an async request and return the response for SSE line parsing."""
217
+ if headers is None:
218
+ headers = build_headers(api_key=self._config.api_key)
219
+ try:
220
+ resp = await self._http.request(
221
+ method, path, json=json, headers=headers, timeout=timeout,
222
+ )
223
+ except httpx.ConnectError as e:
224
+ raise APIConnectionError(f"Connection error: {e}") from e
225
+ except httpx.TimeoutException as e:
226
+ raise APIConnectionError(f"Request timed out: {e}") from e
227
+ raise_for_status(resp)
228
+ return resp
229
+
230
+ async def close(self) -> None:
231
+ """Close the underlying async HTTP client."""
232
+ await self._http.aclose()
233
+
234
+ async def __aenter__(self) -> AsyncClient:
235
+ return self
236
+
237
+ async def __aexit__(self, *args: Any) -> None:
238
+ await self.close()
@@ -0,0 +1,99 @@
1
+ """Exception hierarchy for SPT Models client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ import httpx
7
+
8
+
9
+ class SPTError(Exception):
10
+ """Base exception for all SPT Models client errors."""
11
+
12
+ def __init__(
13
+ self,
14
+ message: str,
15
+ *,
16
+ status_code: int | None = None,
17
+ response: httpx.Response | None = None,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.status_code = status_code
21
+ self.response = response
22
+
23
+
24
+ class AuthenticationError(SPTError):
25
+ """Raised on 401/403 — invalid or missing API key / admin token."""
26
+
27
+
28
+ class RateLimitError(SPTError):
29
+ """Raised on 429 — rate limit exceeded."""
30
+
31
+ def __init__(
32
+ self,
33
+ message: str,
34
+ *,
35
+ status_code: int = 429,
36
+ response: httpx.Response | None = None,
37
+ retry_after: int | None = None,
38
+ ) -> None:
39
+ super().__init__(message, status_code=status_code, response=response)
40
+ self.retry_after = retry_after
41
+
42
+
43
+ class NotFoundError(SPTError):
44
+ """Raised on 404 — model, key, or resource not found."""
45
+
46
+
47
+ class ValidationError(SPTError):
48
+ """Raised on 400/409/413 — bad request, conflict, or payload too large."""
49
+
50
+
51
+ class UpstreamError(SPTError):
52
+ """Raised on 502 — orchestrator or worker unreachable."""
53
+
54
+
55
+ class APIConnectionError(SPTError):
56
+ """Raised when the HTTP connection fails entirely."""
57
+
58
+
59
+ class APIStatusError(SPTError):
60
+ """Raised on unexpected HTTP status codes."""
61
+
62
+
63
+ def _extract_detail(resp: httpx.Response) -> str:
64
+ """Extract error message from response body."""
65
+ try:
66
+ body = resp.json()
67
+ if isinstance(body, dict) and "detail" in body:
68
+ detail = body["detail"]
69
+ return detail if isinstance(detail, str) else str(detail)
70
+ except Exception:
71
+ pass
72
+ return resp.text or f"HTTP {resp.status_code}"
73
+
74
+
75
+ def raise_for_status(resp: httpx.Response) -> None:
76
+ """Raise a typed SPTError if the response indicates an error."""
77
+ code = resp.status_code
78
+ if code < 400:
79
+ return
80
+
81
+ message = _extract_detail(resp)
82
+
83
+ if code in (401, 403):
84
+ raise AuthenticationError(message, status_code=code, response=resp)
85
+ if code == 404:
86
+ raise NotFoundError(message, status_code=code, response=resp)
87
+ if code == 429:
88
+ retry = resp.headers.get("Retry-After")
89
+ raise RateLimitError(
90
+ message,
91
+ status_code=code,
92
+ response=resp,
93
+ retry_after=int(retry) if retry else None,
94
+ )
95
+ if code in (400, 409, 413):
96
+ raise ValidationError(message, status_code=code, response=resp)
97
+ if code == 502:
98
+ raise UpstreamError(message, status_code=code, response=resp)
99
+ raise APIStatusError(message, status_code=code, response=resp)
@@ -0,0 +1,82 @@
1
+ """SSE (Server-Sent Events) stream parsing for sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Iterator, AsyncIterator, TypeVar, Generic, Type
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from ._exceptions import SPTError
11
+
12
+ T = TypeVar("T", bound=BaseModel)
13
+
14
+
15
+ def parse_sse_line(line: str) -> dict[str, Any] | None:
16
+ """Parse a single SSE line. Returns parsed JSON, None for [DONE]/empty, or raises on error."""
17
+ line = line.strip()
18
+ if not line or line.startswith(":"):
19
+ return None
20
+ if line.startswith("data: "):
21
+ data = line[6:]
22
+ if data == "[DONE]":
23
+ return None
24
+ parsed = json.loads(data)
25
+ # Check for error chunks
26
+ choices = parsed.get("choices", [])
27
+ if choices and choices[0].get("finish_reason") == "error":
28
+ raise SPTError("Stream error: server returned error chunk during streaming")
29
+ return parsed
30
+ return None
31
+
32
+
33
+ class SyncSSEIterator(Generic[T]):
34
+ """Synchronous iterator over SSE stream, yielding typed Pydantic models."""
35
+
36
+ def __init__(self, response: Any, model_class: Type[T]) -> None:
37
+ self._response = response
38
+ self._model_class = model_class
39
+ # Parse lines from the response text
40
+ self._lines = iter(response.text.splitlines())
41
+
42
+ def __iter__(self) -> Iterator[T]:
43
+ return self
44
+
45
+ def __next__(self) -> T:
46
+ while True:
47
+ try:
48
+ line = next(self._lines)
49
+ except StopIteration:
50
+ raise
51
+ parsed = parse_sse_line(line)
52
+ if parsed is not None:
53
+ return self._model_class.model_validate(parsed)
54
+
55
+ def close(self) -> None:
56
+ pass
57
+
58
+
59
+ class AsyncSSEIterator(Generic[T]):
60
+ """Async iterator over SSE stream, yielding typed Pydantic models."""
61
+
62
+ def __init__(self, response: Any, model_class: Type[T]) -> None:
63
+ self._response = response
64
+ self._model_class = model_class
65
+ self._iter = response.aiter_lines()
66
+
67
+ def __aiter__(self) -> AsyncIterator[T]:
68
+ return self
69
+
70
+ async def __anext__(self) -> T:
71
+ while True:
72
+ try:
73
+ line = await self._iter.__anext__()
74
+ except StopAsyncIteration:
75
+ await self._response.aclose()
76
+ raise
77
+ parsed = parse_sse_line(line)
78
+ if parsed is not None:
79
+ return self._model_class.model_validate(parsed)
80
+
81
+ async def close(self) -> None:
82
+ await self._response.aclose()
@@ -0,0 +1 @@
1
+ """Resource namespace modules for the SPT client."""
@@ -0,0 +1,52 @@
1
+ """Admin resource namespace — client.admin.*"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ...types.admin import AdminHealth
8
+ from .dashboard import AdminDashboardResource
9
+ from .models import AdminModelsResource
10
+ from .aliases import AdminAliasesResource
11
+ from .keys import AdminKeysResource
12
+ from .usage import AdminUsageResource
13
+ from .gpu import AdminGPUResource
14
+ from .conversion import AdminConversionResource
15
+
16
+
17
+ class AdminResource:
18
+ def __init__(self, client: Any) -> None:
19
+ self._client = client
20
+ self.dashboard = AdminDashboardResource(client)
21
+ self.models = AdminModelsResource(client)
22
+ self.aliases = AdminAliasesResource(client)
23
+ self.keys = AdminKeysResource(client)
24
+ self.usage = AdminUsageResource(client)
25
+ self.gpu = AdminGPUResource(client)
26
+ self.conversion = AdminConversionResource(client)
27
+
28
+ def login(self, *, timeout: float | None = None) -> dict[str, Any]:
29
+ resp = self._client._request(
30
+ "POST", "/admin/api/auth/login",
31
+ json={"token": self._client._config.admin_token},
32
+ admin=True, timeout=timeout,
33
+ )
34
+ return resp.json()
35
+
36
+ async def alogin(self, *, timeout: float | None = None) -> dict[str, Any]:
37
+ resp = await self._client._request(
38
+ "POST", "/admin/api/auth/login",
39
+ json={"token": self._client._config.admin_token},
40
+ admin=True, timeout=timeout,
41
+ )
42
+ return resp.json()
43
+
44
+ def health(self, *, timeout: float | None = None) -> AdminHealth:
45
+ resp = self._client._request("GET", "/admin/api/health", admin=True, timeout=timeout)
46
+ return AdminHealth.model_validate(resp.json())
47
+
48
+ async def ahealth(self, *, timeout: float | None = None) -> AdminHealth:
49
+ resp = await self._client._request(
50
+ "GET", "/admin/api/health", admin=True, timeout=timeout
51
+ )
52
+ return AdminHealth.model_validate(resp.json())
@@ -0,0 +1,91 @@
1
+ """Admin model aliases resource — client-facing names that point at a catalogue model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TYPE_CHECKING
6
+
7
+ from ...types.admin import AdminAlias
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+
13
+ class AdminAliasesResource:
14
+ def __init__(self, client: Any) -> None:
15
+ self._client = client
16
+
17
+ def list(self, *, timeout: float | None = None) -> list[AdminAlias]:
18
+ resp = self._client._request("GET", "/admin/api/aliases", admin=True, timeout=timeout)
19
+ return [AdminAlias.model_validate(a) for a in resp.json()]
20
+
21
+ async def alist(self, *, timeout: float | None = None) -> list[AdminAlias]:
22
+ resp = await self._client._request(
23
+ "GET", "/admin/api/aliases", admin=True, timeout=timeout
24
+ )
25
+ return [AdminAlias.model_validate(a) for a in resp.json()]
26
+
27
+ def create(
28
+ self,
29
+ alias: str,
30
+ model_id: int,
31
+ *,
32
+ description: str = "",
33
+ enabled: bool = True,
34
+ timeout: float | None = None,
35
+ ) -> AdminAlias:
36
+ body: dict[str, Any] = {
37
+ "alias": alias,
38
+ "model_id": model_id,
39
+ "description": description,
40
+ "enabled": enabled,
41
+ }
42
+ resp = self._client._request(
43
+ "POST", "/admin/api/aliases", json=body, admin=True, timeout=timeout
44
+ )
45
+ return AdminAlias.model_validate(resp.json())
46
+
47
+ async def acreate(
48
+ self,
49
+ alias: str,
50
+ model_id: int,
51
+ *,
52
+ description: str = "",
53
+ enabled: bool = True,
54
+ timeout: float | None = None,
55
+ ) -> AdminAlias:
56
+ body: dict[str, Any] = {
57
+ "alias": alias,
58
+ "model_id": model_id,
59
+ "description": description,
60
+ "enabled": enabled,
61
+ }
62
+ resp = await self._client._request(
63
+ "POST", "/admin/api/aliases", json=body, admin=True, timeout=timeout
64
+ )
65
+ return AdminAlias.model_validate(resp.json())
66
+
67
+ def update(
68
+ self, alias_id: int, *, timeout: float | None = None, **kwargs: Any
69
+ ) -> AdminAlias:
70
+ resp = self._client._request(
71
+ "PUT", f"/admin/api/aliases/{alias_id}", json=kwargs, admin=True, timeout=timeout
72
+ )
73
+ return AdminAlias.model_validate(resp.json())
74
+
75
+ async def aupdate(
76
+ self, alias_id: int, *, timeout: float | None = None, **kwargs: Any
77
+ ) -> AdminAlias:
78
+ resp = await self._client._request(
79
+ "PUT", f"/admin/api/aliases/{alias_id}", json=kwargs, admin=True, timeout=timeout
80
+ )
81
+ return AdminAlias.model_validate(resp.json())
82
+
83
+ def delete(self, alias_id: int, *, timeout: float | None = None) -> None:
84
+ self._client._request(
85
+ "DELETE", f"/admin/api/aliases/{alias_id}", admin=True, timeout=timeout
86
+ )
87
+
88
+ async def adelete(self, alias_id: int, *, timeout: float | None = None) -> None:
89
+ await self._client._request(
90
+ "DELETE", f"/admin/api/aliases/{alias_id}", admin=True, timeout=timeout
91
+ )