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 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
+ )
@@ -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
+ )
@@ -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})"