runcrate-sdk 0.1.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.
- runcrate/__init__.py +44 -0
- runcrate/_client.py +323 -0
- runcrate/_config.py +51 -0
- runcrate/_exceptions.py +147 -0
- runcrate/_pagination.py +40 -0
- runcrate/_transport.py +211 -0
- runcrate/models/__init__.py +41 -0
- runcrate/models/billing.py +36 -0
- runcrate/models/environments.py +27 -0
- runcrate/models/instances.py +68 -0
- runcrate/models/models.py +172 -0
- runcrate/models/shared.py +15 -0
- runcrate/models/ssh_keys.py +26 -0
- runcrate/models/storage.py +44 -0
- runcrate/models/templates.py +18 -0
- runcrate/resources/__init__.py +26 -0
- runcrate/resources/billing.py +103 -0
- runcrate/resources/environments.py +95 -0
- runcrate/resources/instances.py +189 -0
- runcrate/resources/models.py +502 -0
- runcrate/resources/ssh_keys.py +69 -0
- runcrate/resources/storage.py +121 -0
- runcrate/resources/templates.py +77 -0
- runcrate_sdk-0.1.0.dist-info/METADATA +276 -0
- runcrate_sdk-0.1.0.dist-info/RECORD +26 -0
- runcrate_sdk-0.1.0.dist-info/WHEEL +4 -0
runcrate/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Runcrate Python SDK — Official client for the Runcrate API."""
|
|
2
|
+
|
|
3
|
+
from runcrate._client import AsyncRuncrate, Runcrate
|
|
4
|
+
from runcrate._exceptions import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
UnprocessableEntityError,
|
|
10
|
+
ConnectionError,
|
|
11
|
+
InsufficientCreditsError,
|
|
12
|
+
InternalServerError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
RuncrateError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
)
|
|
19
|
+
from runcrate._pagination import PaginatedResponse
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AsyncRuncrate",
|
|
25
|
+
"Runcrate",
|
|
26
|
+
# Exceptions
|
|
27
|
+
"ApiError",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"BadRequestError",
|
|
30
|
+
"ConflictError",
|
|
31
|
+
"UnprocessableEntityError",
|
|
32
|
+
"ConnectionError",
|
|
33
|
+
"InsufficientCreditsError",
|
|
34
|
+
"InternalServerError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"RuncrateError",
|
|
39
|
+
"TimeoutError",
|
|
40
|
+
# Pagination
|
|
41
|
+
"PaginatedResponse",
|
|
42
|
+
# Version
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
runcrate/_client.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Main client classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterator, AsyncIterator, Optional, Union
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from runcrate._config import ClientConfig
|
|
10
|
+
from runcrate._transport import AsyncTransport, SyncTransport
|
|
11
|
+
from runcrate.resources.billing import AsyncBilling, Billing
|
|
12
|
+
from runcrate.resources.environments import AsyncEnvironments, Environments
|
|
13
|
+
from runcrate.resources.instances import AsyncInstances, Instances
|
|
14
|
+
from runcrate.resources.models import AsyncModels, Models
|
|
15
|
+
from runcrate.resources.ssh_keys import AsyncSSHKeys, SSHKeys
|
|
16
|
+
from runcrate.resources.storage import AsyncStorage, Storage
|
|
17
|
+
from runcrate.resources.templates import AsyncTemplates, Templates
|
|
18
|
+
from runcrate.models.models import ChatCompletion, ImageGeneration, Transcription, VideoJob
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ─── OpenAI-compatible namespace classes (sync) ────────────────────────────────
|
|
22
|
+
|
|
23
|
+
class _ChatCompletions:
|
|
24
|
+
"""OpenAI-compatible chat.completions namespace."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, models: Models) -> None:
|
|
27
|
+
self._models = models
|
|
28
|
+
|
|
29
|
+
def create(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
model: str,
|
|
33
|
+
messages: list[dict[str, Any]],
|
|
34
|
+
stream: bool = False,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> Union[ChatCompletion, Iterator[dict[str, Any]]]:
|
|
37
|
+
return self._models.chat_completion(model=model, messages=messages, stream=stream, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Chat:
|
|
41
|
+
"""OpenAI-compatible chat namespace."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, models: Models) -> None:
|
|
44
|
+
self.completions = _ChatCompletions(models)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Images:
|
|
48
|
+
"""OpenAI-compatible images namespace."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, models: Models) -> None:
|
|
51
|
+
self._models = models
|
|
52
|
+
|
|
53
|
+
def generate(self, *, model: str, prompt: str, **kwargs: Any) -> ImageGeneration:
|
|
54
|
+
return self._models.generate_image(model=model, prompt=prompt, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Speech:
|
|
58
|
+
"""OpenAI-compatible audio.speech namespace."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, models: Models) -> None:
|
|
61
|
+
self._models = models
|
|
62
|
+
|
|
63
|
+
def create(self, *, model: str, input: str, **kwargs: Any) -> bytes:
|
|
64
|
+
return self._models.text_to_speech(model=model, input=input, **kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Transcriptions:
|
|
68
|
+
"""OpenAI-compatible audio.transcriptions namespace."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, models: Models) -> None:
|
|
71
|
+
self._models = models
|
|
72
|
+
|
|
73
|
+
def create(self, *, model: str, file: Any, **kwargs: Any) -> Transcription:
|
|
74
|
+
return self._models.transcribe(model=model, file=file, **kwargs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Audio:
|
|
78
|
+
"""OpenAI-compatible audio namespace."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, models: Models) -> None:
|
|
81
|
+
self.speech = _Speech(models)
|
|
82
|
+
self.transcriptions = _Transcriptions(models)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Videos:
|
|
86
|
+
"""Video namespace."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, models: Models) -> None:
|
|
89
|
+
self._models = models
|
|
90
|
+
|
|
91
|
+
def generate(self, *, model: str, prompt: str, **kwargs: Any) -> VideoJob:
|
|
92
|
+
return self._models.generate_video(model=model, prompt=prompt, **kwargs)
|
|
93
|
+
|
|
94
|
+
def get_status(self, id: str) -> VideoJob:
|
|
95
|
+
return self._models.get_video_status(id)
|
|
96
|
+
|
|
97
|
+
def download(self, id: str) -> bytes:
|
|
98
|
+
return self._models.download_video(id)
|
|
99
|
+
|
|
100
|
+
def generate_and_save(self, path: str, *, model: str, prompt: str, **kwargs: Any) -> VideoJob:
|
|
101
|
+
return self._models.generate_video_and_save(path, model=model, prompt=prompt, **kwargs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ─── OpenAI-compatible namespace classes (async) ───────────────────────────────
|
|
105
|
+
|
|
106
|
+
class _AsyncChatCompletions:
|
|
107
|
+
"""OpenAI-compatible chat.completions namespace (async)."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
110
|
+
self._models = models
|
|
111
|
+
|
|
112
|
+
async def create(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
model: str,
|
|
116
|
+
messages: list[dict[str, Any]],
|
|
117
|
+
stream: bool = False,
|
|
118
|
+
**kwargs: Any,
|
|
119
|
+
) -> Union[ChatCompletion, AsyncIterator[dict[str, Any]]]:
|
|
120
|
+
return await self._models.chat_completion(model=model, messages=messages, stream=stream, **kwargs)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _AsyncChat:
|
|
124
|
+
"""OpenAI-compatible chat namespace (async)."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
127
|
+
self.completions = _AsyncChatCompletions(models)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _AsyncImages:
|
|
131
|
+
"""OpenAI-compatible images namespace (async)."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
134
|
+
self._models = models
|
|
135
|
+
|
|
136
|
+
async def generate(self, *, model: str, prompt: str, **kwargs: Any) -> ImageGeneration:
|
|
137
|
+
return await self._models.generate_image(model=model, prompt=prompt, **kwargs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _AsyncSpeech:
|
|
141
|
+
"""OpenAI-compatible audio.speech namespace (async)."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
144
|
+
self._models = models
|
|
145
|
+
|
|
146
|
+
async def create(self, *, model: str, input: str, **kwargs: Any) -> bytes:
|
|
147
|
+
return await self._models.text_to_speech(model=model, input=input, **kwargs)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _AsyncTranscriptions:
|
|
151
|
+
"""OpenAI-compatible audio.transcriptions namespace (async)."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
154
|
+
self._models = models
|
|
155
|
+
|
|
156
|
+
async def create(self, *, model: str, file: Any, **kwargs: Any) -> Transcription:
|
|
157
|
+
return await self._models.transcribe(model=model, file=file, **kwargs)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _AsyncAudio:
|
|
161
|
+
"""OpenAI-compatible audio namespace (async)."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
164
|
+
self.speech = _AsyncSpeech(models)
|
|
165
|
+
self.transcriptions = _AsyncTranscriptions(models)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _AsyncVideos:
|
|
169
|
+
"""Video namespace (async)."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, models: AsyncModels) -> None:
|
|
172
|
+
self._models = models
|
|
173
|
+
|
|
174
|
+
async def generate(self, *, model: str, prompt: str, **kwargs: Any) -> VideoJob:
|
|
175
|
+
return await self._models.generate_video(model=model, prompt=prompt, **kwargs)
|
|
176
|
+
|
|
177
|
+
async def get_status(self, id: str) -> VideoJob:
|
|
178
|
+
return await self._models.get_video_status(id)
|
|
179
|
+
|
|
180
|
+
async def download(self, id: str) -> bytes:
|
|
181
|
+
return await self._models.download_video(id)
|
|
182
|
+
|
|
183
|
+
async def generate_and_save(self, path: str, *, model: str, prompt: str, **kwargs: Any) -> VideoJob:
|
|
184
|
+
return await self._models.generate_video_and_save(path, model=model, prompt=prompt, **kwargs)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _make_headers(api_key: str) -> dict[str, str]:
|
|
188
|
+
return {
|
|
189
|
+
"Authorization": f"Bearer {api_key}",
|
|
190
|
+
"User-Agent": "runcrate-python/0.1.0",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Runcrate:
|
|
195
|
+
"""Synchronous Runcrate API client.
|
|
196
|
+
|
|
197
|
+
Usage::
|
|
198
|
+
|
|
199
|
+
from runcrate import Runcrate
|
|
200
|
+
|
|
201
|
+
client = Runcrate(api_key="rc_live_...")
|
|
202
|
+
instances = client.instances.list()
|
|
203
|
+
client.close()
|
|
204
|
+
|
|
205
|
+
Or as a context manager::
|
|
206
|
+
|
|
207
|
+
with Runcrate(api_key="rc_live_...") as client:
|
|
208
|
+
instances = client.instances.list()
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
api_key: Optional[str] = None,
|
|
214
|
+
*,
|
|
215
|
+
base_url: Optional[str] = None,
|
|
216
|
+
inference_url: Optional[str] = None,
|
|
217
|
+
timeout: Optional[float] = None,
|
|
218
|
+
max_retries: Optional[int] = None,
|
|
219
|
+
environment: Optional[str] = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
self._config = ClientConfig.from_args(
|
|
222
|
+
api_key=api_key,
|
|
223
|
+
base_url=base_url,
|
|
224
|
+
inference_url=inference_url,
|
|
225
|
+
timeout=timeout,
|
|
226
|
+
max_retries=max_retries,
|
|
227
|
+
environment=environment,
|
|
228
|
+
)
|
|
229
|
+
self._transport = SyncTransport(self._config)
|
|
230
|
+
|
|
231
|
+
# Separate httpx client for inference API (api.runcrate.ai)
|
|
232
|
+
self._inference_client = httpx.Client(
|
|
233
|
+
base_url=self._config.inference_url,
|
|
234
|
+
headers=_make_headers(self._config.api_key),
|
|
235
|
+
timeout=self._config.timeout,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
self.instances = Instances(self._transport)
|
|
239
|
+
self.environments = Environments(self._transport)
|
|
240
|
+
self.ssh_keys = SSHKeys(self._transport)
|
|
241
|
+
self.storage = Storage(self._transport)
|
|
242
|
+
self.billing = Billing(self._transport)
|
|
243
|
+
self.templates = Templates(self._transport)
|
|
244
|
+
self.models = Models(self._inference_client)
|
|
245
|
+
|
|
246
|
+
# OpenAI-compatible aliases
|
|
247
|
+
self.chat = _Chat(self.models)
|
|
248
|
+
self.images = _Images(self.models)
|
|
249
|
+
self.audio = _Audio(self.models)
|
|
250
|
+
self.videos = _Videos(self.models)
|
|
251
|
+
|
|
252
|
+
def close(self) -> None:
|
|
253
|
+
self._transport.close()
|
|
254
|
+
self._inference_client.close()
|
|
255
|
+
|
|
256
|
+
def __enter__(self) -> Runcrate:
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def __exit__(self, *args: object) -> None:
|
|
260
|
+
self.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class AsyncRuncrate:
|
|
264
|
+
"""Asynchronous Runcrate API client.
|
|
265
|
+
|
|
266
|
+
Usage::
|
|
267
|
+
|
|
268
|
+
from runcrate import AsyncRuncrate
|
|
269
|
+
|
|
270
|
+
async with AsyncRuncrate(api_key="rc_live_...") as client:
|
|
271
|
+
instances = await client.instances.list()
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
api_key: Optional[str] = None,
|
|
277
|
+
*,
|
|
278
|
+
base_url: Optional[str] = None,
|
|
279
|
+
inference_url: Optional[str] = None,
|
|
280
|
+
timeout: Optional[float] = None,
|
|
281
|
+
max_retries: Optional[int] = None,
|
|
282
|
+
environment: Optional[str] = None,
|
|
283
|
+
) -> None:
|
|
284
|
+
self._config = ClientConfig.from_args(
|
|
285
|
+
api_key=api_key,
|
|
286
|
+
base_url=base_url,
|
|
287
|
+
inference_url=inference_url,
|
|
288
|
+
timeout=timeout,
|
|
289
|
+
max_retries=max_retries,
|
|
290
|
+
environment=environment,
|
|
291
|
+
)
|
|
292
|
+
self._transport = AsyncTransport(self._config)
|
|
293
|
+
|
|
294
|
+
# Separate httpx client for inference API (api.runcrate.ai)
|
|
295
|
+
self._inference_client = httpx.AsyncClient(
|
|
296
|
+
base_url=self._config.inference_url,
|
|
297
|
+
headers=_make_headers(self._config.api_key),
|
|
298
|
+
timeout=self._config.timeout,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
self.instances = AsyncInstances(self._transport)
|
|
302
|
+
self.environments = AsyncEnvironments(self._transport)
|
|
303
|
+
self.ssh_keys = AsyncSSHKeys(self._transport)
|
|
304
|
+
self.storage = AsyncStorage(self._transport)
|
|
305
|
+
self.billing = AsyncBilling(self._transport)
|
|
306
|
+
self.templates = AsyncTemplates(self._transport)
|
|
307
|
+
self.models = AsyncModels(self._inference_client)
|
|
308
|
+
|
|
309
|
+
# OpenAI-compatible aliases
|
|
310
|
+
self.chat = _AsyncChat(self.models)
|
|
311
|
+
self.images = _AsyncImages(self.models)
|
|
312
|
+
self.audio = _AsyncAudio(self.models)
|
|
313
|
+
self.videos = _AsyncVideos(self.models)
|
|
314
|
+
|
|
315
|
+
async def close(self) -> None:
|
|
316
|
+
await self._transport.close()
|
|
317
|
+
await self._inference_client.aclose()
|
|
318
|
+
|
|
319
|
+
async def __aenter__(self) -> AsyncRuncrate:
|
|
320
|
+
return self
|
|
321
|
+
|
|
322
|
+
async def __aexit__(self, *args: object) -> None:
|
|
323
|
+
await self.close()
|
runcrate/_config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Client configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DEFAULT_BASE_URL = "https://runcrate.ai"
|
|
11
|
+
_DEFAULT_INFERENCE_URL = "https://api.runcrate.ai"
|
|
12
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
13
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ClientConfig:
|
|
18
|
+
api_key: str
|
|
19
|
+
base_url: str = _DEFAULT_BASE_URL
|
|
20
|
+
inference_url: str = _DEFAULT_INFERENCE_URL
|
|
21
|
+
timeout: float = _DEFAULT_TIMEOUT
|
|
22
|
+
max_retries: int = _DEFAULT_MAX_RETRIES
|
|
23
|
+
custom_headers: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
environment: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_args(
|
|
28
|
+
cls,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
base_url: Optional[str] = None,
|
|
31
|
+
inference_url: Optional[str] = None,
|
|
32
|
+
timeout: Optional[float] = None,
|
|
33
|
+
max_retries: Optional[int] = None,
|
|
34
|
+
environment: Optional[str] = None,
|
|
35
|
+
) -> ClientConfig:
|
|
36
|
+
resolved_key = api_key or os.environ.get("RUNCRATE_API_KEY", "")
|
|
37
|
+
if not resolved_key:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"API key is required. Pass api_key= or set the RUNCRATE_API_KEY environment variable."
|
|
40
|
+
)
|
|
41
|
+
if not resolved_key.startswith("rc_live_"):
|
|
42
|
+
raise ValueError("Invalid API key format. Keys must start with 'rc_live_'.")
|
|
43
|
+
|
|
44
|
+
return cls(
|
|
45
|
+
api_key=resolved_key,
|
|
46
|
+
base_url=(base_url or _DEFAULT_BASE_URL).rstrip("/"),
|
|
47
|
+
inference_url=(inference_url or _DEFAULT_INFERENCE_URL).rstrip("/"),
|
|
48
|
+
timeout=timeout if timeout is not None else _DEFAULT_TIMEOUT,
|
|
49
|
+
max_retries=max_retries if max_retries is not None else _DEFAULT_MAX_RETRIES,
|
|
50
|
+
environment=environment,
|
|
51
|
+
)
|
runcrate/_exceptions.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Exception hierarchy for the Runcrate SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RuncrateError(Exception):
|
|
11
|
+
"""Base exception for all Runcrate SDK errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str) -> None:
|
|
14
|
+
self.message = message
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(RuncrateError):
|
|
19
|
+
"""Raised when the API returns a non-2xx response."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status_code: int,
|
|
26
|
+
code: str,
|
|
27
|
+
details: Optional[dict[str, Any]] = None,
|
|
28
|
+
response: Optional[httpx.Response] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.code = code
|
|
32
|
+
self.details = details
|
|
33
|
+
self.response = response
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"{self.__class__.__name__}(status_code={self.status_code}, code={self.code!r}, message={self.message!r})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BadRequestError(ApiError):
|
|
41
|
+
"""400 - Invalid request parameters."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthenticationError(ApiError):
|
|
45
|
+
"""401 - Invalid or missing API key."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InsufficientCreditsError(ApiError):
|
|
49
|
+
"""402 - Not enough credits to perform operation."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PermissionDeniedError(ApiError):
|
|
53
|
+
"""403 - User lacks permission."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotFoundError(ApiError):
|
|
57
|
+
"""404 - Resource not found."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConflictError(ApiError):
|
|
61
|
+
"""409 - Resource conflict (e.g., duplicate SSH key)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UnprocessableEntityError(ApiError):
|
|
65
|
+
"""422 - Validation error (e.g., invalid model parameters)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RateLimitError(ApiError):
|
|
69
|
+
"""429 - Rate limit exceeded."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InternalServerError(ApiError):
|
|
73
|
+
"""500 - Server error."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ConnectionError(RuncrateError):
|
|
77
|
+
"""Network connectivity failure."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TimeoutError(RuncrateError):
|
|
81
|
+
"""Request timed out."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_STATUS_MAP: dict[int, type[ApiError]] = {
|
|
85
|
+
400: BadRequestError,
|
|
86
|
+
401: AuthenticationError,
|
|
87
|
+
402: InsufficientCreditsError,
|
|
88
|
+
403: PermissionDeniedError,
|
|
89
|
+
404: NotFoundError,
|
|
90
|
+
409: ConflictError,
|
|
91
|
+
422: UnprocessableEntityError,
|
|
92
|
+
429: RateLimitError,
|
|
93
|
+
500: InternalServerError,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extract_error_info(body: Any) -> tuple[Optional[str], str, Optional[dict[str, Any]]]:
|
|
98
|
+
"""Extract message, code, and details from various error response formats."""
|
|
99
|
+
if not isinstance(body, dict):
|
|
100
|
+
return None, "api_error", None
|
|
101
|
+
|
|
102
|
+
# Format: { error: { code, message, details } }
|
|
103
|
+
err = body.get("error")
|
|
104
|
+
if isinstance(err, dict):
|
|
105
|
+
return (
|
|
106
|
+
err.get("message"),
|
|
107
|
+
err.get("code", "api_error"),
|
|
108
|
+
err.get("details"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Format: { error: "string message" }
|
|
112
|
+
if isinstance(err, str):
|
|
113
|
+
return err, "api_error", None
|
|
114
|
+
|
|
115
|
+
# Format: { message: "string" }
|
|
116
|
+
if isinstance(body.get("message"), str):
|
|
117
|
+
return body["message"], body.get("code", "api_error"), None
|
|
118
|
+
|
|
119
|
+
# Format: { detail: "string" } (FastAPI style)
|
|
120
|
+
if isinstance(body.get("detail"), str):
|
|
121
|
+
return body["detail"], "api_error", None
|
|
122
|
+
|
|
123
|
+
return None, "api_error", None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def make_api_error(response: httpx.Response) -> ApiError:
|
|
127
|
+
"""Create the appropriate ApiError subclass from an HTTP response."""
|
|
128
|
+
try:
|
|
129
|
+
body = response.json()
|
|
130
|
+
except Exception:
|
|
131
|
+
body = None
|
|
132
|
+
|
|
133
|
+
extracted_message, code, details = _extract_error_info(body)
|
|
134
|
+
|
|
135
|
+
# Fallback: use raw response text if we couldn't extract a message
|
|
136
|
+
if extracted_message is None:
|
|
137
|
+
text = response.text or ""
|
|
138
|
+
extracted_message = text[:500] if text else f"HTTP {response.status_code}"
|
|
139
|
+
|
|
140
|
+
cls = _STATUS_MAP.get(response.status_code, ApiError)
|
|
141
|
+
return cls(
|
|
142
|
+
extracted_message,
|
|
143
|
+
status_code=response.status_code,
|
|
144
|
+
code=code,
|
|
145
|
+
details=details,
|
|
146
|
+
response=response,
|
|
147
|
+
)
|
runcrate/_pagination.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Pagination helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generic, List, Optional, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from runcrate.models.shared import ListMeta
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PaginatedResponse(Generic[T]):
|
|
15
|
+
"""A single page of results with metadata."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, data: List[T], meta: Optional[ListMeta] = None) -> None:
|
|
18
|
+
self.data = data
|
|
19
|
+
self.meta = meta or ListMeta()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def has_more(self) -> bool:
|
|
23
|
+
return self.meta.has_more or False
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def cursor(self) -> Optional[str]:
|
|
27
|
+
return self.meta.cursor
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def total(self) -> Optional[int]:
|
|
31
|
+
return self.meta.total
|
|
32
|
+
|
|
33
|
+
def __iter__(self):
|
|
34
|
+
return iter(self.data)
|
|
35
|
+
|
|
36
|
+
def __len__(self) -> int:
|
|
37
|
+
return len(self.data)
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"PaginatedResponse(count={len(self.data)}, has_more={self.has_more}, total={self.total})"
|