cubic-sdk 0.3.2__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.
- cubic/__init__.py +91 -0
- cubic/_async_client.py +130 -0
- cubic/_client.py +247 -0
- cubic/_exceptions.py +171 -0
- cubic/_version.py +1 -0
- cubic/py.typed +0 -0
- cubic/resources/__init__.py +0 -0
- cubic/resources/completions.py +406 -0
- cubic/resources/cubes.py +50 -0
- cubic/resources/models.py +114 -0
- cubic/types.py +248 -0
- cubic/webhooks.py +107 -0
- cubic_sdk-0.3.2.dist-info/METADATA +213 -0
- cubic_sdk-0.3.2.dist-info/RECORD +15 -0
- cubic_sdk-0.3.2.dist-info/WHEEL +4 -0
cubic/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Cubic Python SDK.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from cubic import Cubic
|
|
6
|
+
|
|
7
|
+
client = Cubic(api_key="mxk_...")
|
|
8
|
+
result = client.completions.create(
|
|
9
|
+
cube_id="cbe_a1B2c3D4e5F6g7",
|
|
10
|
+
variables={"customer_name": "Ada"},
|
|
11
|
+
)
|
|
12
|
+
print(result.content)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from . import webhooks
|
|
16
|
+
from ._async_client import AsyncCubic
|
|
17
|
+
from ._client import Cubic
|
|
18
|
+
from ._exceptions import (
|
|
19
|
+
APIConnectionError,
|
|
20
|
+
APITimeoutError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
CompletionError,
|
|
23
|
+
CompletionNotFoundError,
|
|
24
|
+
CompletionTimeoutError,
|
|
25
|
+
CubeNotFoundError,
|
|
26
|
+
CubicError,
|
|
27
|
+
InsufficientCreditsError,
|
|
28
|
+
InternalServerError,
|
|
29
|
+
InvalidRequestError,
|
|
30
|
+
MissingVariableError,
|
|
31
|
+
ModelNotFoundError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
PermissionDeniedError,
|
|
34
|
+
ProviderError,
|
|
35
|
+
RateLimitError,
|
|
36
|
+
VersionNotFoundError,
|
|
37
|
+
WaitTimeoutError,
|
|
38
|
+
WebhookSignatureError,
|
|
39
|
+
)
|
|
40
|
+
from ._version import __version__
|
|
41
|
+
from .types import (
|
|
42
|
+
AttemptError,
|
|
43
|
+
CompletionRecord,
|
|
44
|
+
CompletionResult,
|
|
45
|
+
Cube,
|
|
46
|
+
CubeModel,
|
|
47
|
+
Metrics,
|
|
48
|
+
Model,
|
|
49
|
+
PolycubeResult,
|
|
50
|
+
Segment,
|
|
51
|
+
SingleCompletion,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"Cubic",
|
|
56
|
+
"AsyncCubic",
|
|
57
|
+
"webhooks",
|
|
58
|
+
"__version__",
|
|
59
|
+
# exceptions
|
|
60
|
+
"CubicError",
|
|
61
|
+
"APIConnectionError",
|
|
62
|
+
"APITimeoutError",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"PermissionDeniedError",
|
|
65
|
+
"NotFoundError",
|
|
66
|
+
"ModelNotFoundError",
|
|
67
|
+
"CubeNotFoundError",
|
|
68
|
+
"VersionNotFoundError",
|
|
69
|
+
"CompletionNotFoundError",
|
|
70
|
+
"InvalidRequestError",
|
|
71
|
+
"InsufficientCreditsError",
|
|
72
|
+
"RateLimitError",
|
|
73
|
+
"CompletionTimeoutError",
|
|
74
|
+
"InternalServerError",
|
|
75
|
+
"CompletionError",
|
|
76
|
+
"MissingVariableError",
|
|
77
|
+
"ProviderError",
|
|
78
|
+
"WaitTimeoutError",
|
|
79
|
+
"WebhookSignatureError",
|
|
80
|
+
# types
|
|
81
|
+
"AttemptError",
|
|
82
|
+
"Metrics",
|
|
83
|
+
"SingleCompletion",
|
|
84
|
+
"CompletionResult",
|
|
85
|
+
"PolycubeResult",
|
|
86
|
+
"Segment",
|
|
87
|
+
"CompletionRecord",
|
|
88
|
+
"Cube",
|
|
89
|
+
"CubeModel",
|
|
90
|
+
"Model",
|
|
91
|
+
]
|
cubic/_async_client.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The asynchronous Cubic client — same surface as :class:`cubic.Cubic`, awaitable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _exceptions as err
|
|
12
|
+
from ._client import (
|
|
13
|
+
DEFAULT_BASE_URL,
|
|
14
|
+
DEFAULT_MAX_RETRIES,
|
|
15
|
+
DEFAULT_TIMEOUT,
|
|
16
|
+
classify_retry,
|
|
17
|
+
error_from_response,
|
|
18
|
+
next_delay,
|
|
19
|
+
)
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncCubic:
|
|
24
|
+
"""Asynchronous client for the Cubic API.
|
|
25
|
+
|
|
26
|
+
Accepts the same arguments as :class:`cubic.Cubic`; every resource method
|
|
27
|
+
is a coroutine. Use it as an async context manager or call ``aclose()``.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
api_key: A ``mxk_…`` API key. Falls back to the ``CUBIC_API_KEY``
|
|
31
|
+
environment variable.
|
|
32
|
+
base_url: API origin. Falls back to ``CUBIC_BASE_URL``, then the
|
|
33
|
+
hosted API (https://api.cubic.zone).
|
|
34
|
+
timeout: httpx timeout for all requests.
|
|
35
|
+
max_retries: Automatic retries for transient failures (connection
|
|
36
|
+
errors, capacity 429s, and — when the request is idempotent —
|
|
37
|
+
5xx responses).
|
|
38
|
+
http_client: Bring your own ``httpx.AsyncClient`` (proxies, custom
|
|
39
|
+
transports, testing). The SDK will not close it for you.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str | None = None,
|
|
45
|
+
*,
|
|
46
|
+
base_url: str | None = None,
|
|
47
|
+
timeout: httpx.Timeout | float | None = None,
|
|
48
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
49
|
+
http_client: httpx.AsyncClient | None = None,
|
|
50
|
+
backoff_base: float = 0.5,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.api_key = api_key or os.environ.get("CUBIC_API_KEY")
|
|
53
|
+
if not self.api_key:
|
|
54
|
+
raise err.CubicError(
|
|
55
|
+
"No API key provided. Pass api_key=... or set the CUBIC_API_KEY "
|
|
56
|
+
"environment variable. Keys are created in the Cubic dashboard "
|
|
57
|
+
"and start with 'mxk_'."
|
|
58
|
+
)
|
|
59
|
+
self.base_url = (base_url or os.environ.get("CUBIC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
60
|
+
self.max_retries = max_retries
|
|
61
|
+
self._backoff_base = backoff_base
|
|
62
|
+
self._own_http = http_client is None
|
|
63
|
+
self._http = http_client or httpx.AsyncClient(
|
|
64
|
+
timeout=timeout if timeout is not None else DEFAULT_TIMEOUT
|
|
65
|
+
)
|
|
66
|
+
self._kind_cache: dict[str, str] = {}
|
|
67
|
+
|
|
68
|
+
from .resources.completions import AsyncCompletions
|
|
69
|
+
from .resources.cubes import AsyncCubes
|
|
70
|
+
from .resources.models import AsyncModels
|
|
71
|
+
|
|
72
|
+
self.completions = AsyncCompletions(self)
|
|
73
|
+
self.cubes = AsyncCubes(self)
|
|
74
|
+
self.models = AsyncModels(self)
|
|
75
|
+
|
|
76
|
+
# ---- lifecycle ----
|
|
77
|
+
async def aclose(self) -> None:
|
|
78
|
+
if self._own_http:
|
|
79
|
+
await self._http.aclose()
|
|
80
|
+
|
|
81
|
+
async def __aenter__(self) -> "AsyncCubic":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
85
|
+
await self.aclose()
|
|
86
|
+
|
|
87
|
+
# ---- transport ----
|
|
88
|
+
async def request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
json_body: dict | None = None,
|
|
94
|
+
params: dict | None = None,
|
|
95
|
+
idempotent: bool = False,
|
|
96
|
+
) -> httpx.Response:
|
|
97
|
+
url = f"{self.base_url}{path}"
|
|
98
|
+
headers = {
|
|
99
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
100
|
+
"User-Agent": f"cubic-python/{__version__}",
|
|
101
|
+
}
|
|
102
|
+
attempt = 0
|
|
103
|
+
while True:
|
|
104
|
+
retry_after: float | None = None
|
|
105
|
+
try:
|
|
106
|
+
response = await self._http.request(
|
|
107
|
+
method, url, json=json_body, params=params, headers=headers
|
|
108
|
+
)
|
|
109
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
|
110
|
+
# The request never reached the server — always safe to retry.
|
|
111
|
+
exc: err.CubicError = err.APIConnectionError(f"Could not reach {self.base_url}: {e}")
|
|
112
|
+
retryable = True
|
|
113
|
+
except httpx.TimeoutException as e:
|
|
114
|
+
exc = err.APITimeoutError(f"Request timed out: {e}")
|
|
115
|
+
retryable = idempotent
|
|
116
|
+
except httpx.TransportError as e:
|
|
117
|
+
exc = err.APIConnectionError(f"Transport error: {e}")
|
|
118
|
+
retryable = idempotent
|
|
119
|
+
else:
|
|
120
|
+
if response.status_code < 400:
|
|
121
|
+
return response
|
|
122
|
+
exc = error_from_response(response)
|
|
123
|
+
retryable, retry_after = classify_retry(response, exc, idempotent)
|
|
124
|
+
|
|
125
|
+
if not retryable or attempt >= self.max_retries:
|
|
126
|
+
raise exc
|
|
127
|
+
attempt += 1
|
|
128
|
+
delay = next_delay(attempt, retry_after, self._backoff_base)
|
|
129
|
+
if delay > 0:
|
|
130
|
+
await asyncio.sleep(delay)
|
cubic/_client.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""The synchronous Cubic client: transport, auth, retries, and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from . import _exceptions as err
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
|
|
15
|
+
# The hosted Cubic API. Point CUBIC_BASE_URL or base_url= at another deployment
|
|
16
|
+
# (e.g. http://localhost:8010 for local development).
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.cubic.zone"
|
|
18
|
+
|
|
19
|
+
# Completions can legitimately run for minutes (the server's own execution
|
|
20
|
+
# deadline is ~150s), so the read timeout is generous.
|
|
21
|
+
DEFAULT_TIMEOUT = httpx.Timeout(180.0, connect=5.0)
|
|
22
|
+
DEFAULT_MAX_RETRIES = 2
|
|
23
|
+
|
|
24
|
+
# Statuses safe to retry only when the request carries an idempotency key
|
|
25
|
+
# (client_request_id): the server may or may not have started executing.
|
|
26
|
+
_RETRY_STATUSES_IDEMPOTENT = {500, 502, 503}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def classify_retry(
|
|
30
|
+
response: httpx.Response, exc: err.CubicError, idempotent: bool
|
|
31
|
+
) -> tuple[bool, float | None]:
|
|
32
|
+
"""Decide whether a failed response may be retried, and any server-directed delay."""
|
|
33
|
+
status = response.status_code
|
|
34
|
+
if status == 429:
|
|
35
|
+
# Capacity load-shedding has no error_code and is always safe to
|
|
36
|
+
# retry (the request was never admitted). Credit/quota 429s are
|
|
37
|
+
# caller errors — never retry those.
|
|
38
|
+
if isinstance(exc, err.RateLimitError) and exc.error_code is None:
|
|
39
|
+
return True, exc.retry_after
|
|
40
|
+
return False, None
|
|
41
|
+
if status in _RETRY_STATUSES_IDEMPOTENT and idempotent:
|
|
42
|
+
return True, None
|
|
43
|
+
return False, None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def next_delay(attempt: int, retry_after: float | None, base: float) -> float:
|
|
47
|
+
if retry_after is not None:
|
|
48
|
+
return retry_after
|
|
49
|
+
delay = min(base * (2 ** (attempt - 1)), 8.0)
|
|
50
|
+
return delay + random.uniform(0, delay / 4)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Cubic:
|
|
54
|
+
"""Synchronous client for the Cubic API.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
api_key: A ``mxk_…`` API key. Falls back to the ``CUBIC_API_KEY``
|
|
58
|
+
environment variable.
|
|
59
|
+
base_url: API origin. Falls back to ``CUBIC_BASE_URL``, then the
|
|
60
|
+
hosted API (https://api.cubic.zone).
|
|
61
|
+
timeout: httpx timeout for all requests.
|
|
62
|
+
max_retries: Automatic retries for transient failures (connection
|
|
63
|
+
errors, capacity 429s, and — when the request is idempotent —
|
|
64
|
+
5xx responses).
|
|
65
|
+
http_client: Bring your own ``httpx.Client`` (proxies, custom
|
|
66
|
+
transports, testing). The SDK will not close it for you.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
base_url: str | None = None,
|
|
74
|
+
timeout: httpx.Timeout | float | None = None,
|
|
75
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
76
|
+
http_client: httpx.Client | None = None,
|
|
77
|
+
backoff_base: float = 0.5,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.api_key = api_key or os.environ.get("CUBIC_API_KEY")
|
|
80
|
+
if not self.api_key:
|
|
81
|
+
raise err.CubicError(
|
|
82
|
+
"No API key provided. Pass api_key=... or set the CUBIC_API_KEY "
|
|
83
|
+
"environment variable. Keys are created in the Cubic dashboard "
|
|
84
|
+
"and start with 'mxk_'."
|
|
85
|
+
)
|
|
86
|
+
self.base_url = (base_url or os.environ.get("CUBIC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
87
|
+
self.max_retries = max_retries
|
|
88
|
+
self._backoff_base = backoff_base
|
|
89
|
+
self._own_http = http_client is None
|
|
90
|
+
self._http = http_client or httpx.Client(timeout=timeout if timeout is not None else DEFAULT_TIMEOUT)
|
|
91
|
+
# Remembers which public IDs turned out to be polycubes, so we stop
|
|
92
|
+
# attaching fields the chain path rejects (e.g. client_request_id).
|
|
93
|
+
self._kind_cache: dict[str, str] = {}
|
|
94
|
+
|
|
95
|
+
from .resources.completions import Completions
|
|
96
|
+
from .resources.cubes import Cubes
|
|
97
|
+
from .resources.models import Models
|
|
98
|
+
|
|
99
|
+
self.completions = Completions(self)
|
|
100
|
+
self.cubes = Cubes(self)
|
|
101
|
+
self.models = Models(self)
|
|
102
|
+
|
|
103
|
+
# ---- lifecycle ----
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
if self._own_http:
|
|
106
|
+
self._http.close()
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> "Cubic":
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *exc: Any) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
# ---- transport ----
|
|
115
|
+
def request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
json_body: dict | None = None,
|
|
121
|
+
params: dict | None = None,
|
|
122
|
+
idempotent: bool = False,
|
|
123
|
+
) -> httpx.Response:
|
|
124
|
+
url = f"{self.base_url}{path}"
|
|
125
|
+
headers = {
|
|
126
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
127
|
+
"User-Agent": f"cubic-python/{__version__}",
|
|
128
|
+
}
|
|
129
|
+
attempt = 0
|
|
130
|
+
while True:
|
|
131
|
+
retry_after: float | None = None
|
|
132
|
+
try:
|
|
133
|
+
response = self._http.request(method, url, json=json_body, params=params, headers=headers)
|
|
134
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
|
135
|
+
# The request never reached the server — always safe to retry.
|
|
136
|
+
exc: err.CubicError = err.APIConnectionError(f"Could not reach {self.base_url}: {e}")
|
|
137
|
+
retryable = True
|
|
138
|
+
except httpx.TimeoutException as e:
|
|
139
|
+
exc = err.APITimeoutError(f"Request timed out: {e}")
|
|
140
|
+
retryable = idempotent
|
|
141
|
+
except httpx.TransportError as e:
|
|
142
|
+
exc = err.APIConnectionError(f"Transport error: {e}")
|
|
143
|
+
retryable = idempotent
|
|
144
|
+
else:
|
|
145
|
+
if response.status_code < 400:
|
|
146
|
+
return response
|
|
147
|
+
exc = error_from_response(response)
|
|
148
|
+
retryable, retry_after = classify_retry(response, exc, idempotent)
|
|
149
|
+
|
|
150
|
+
if not retryable or attempt >= self.max_retries:
|
|
151
|
+
raise exc
|
|
152
|
+
attempt += 1
|
|
153
|
+
delay = next_delay(attempt, retry_after, self._backoff_base)
|
|
154
|
+
if delay > 0:
|
|
155
|
+
time.sleep(delay)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _flatten_validation_detail(detail: list[Any]) -> str:
|
|
159
|
+
"""Flatten FastAPI's 422 detail array into one readable message."""
|
|
160
|
+
parts = []
|
|
161
|
+
for item in detail:
|
|
162
|
+
if not isinstance(item, dict):
|
|
163
|
+
parts.append(str(item))
|
|
164
|
+
continue
|
|
165
|
+
loc = item.get("loc") or []
|
|
166
|
+
# drop the leading "body" segment — callers think in field names
|
|
167
|
+
field = ".".join(str(x) for x in loc[1:] if x != "__root__") or ".".join(map(str, loc))
|
|
168
|
+
msg = item.get("msg", "invalid")
|
|
169
|
+
parts.append(f"{field}: {msg}" if field else msg)
|
|
170
|
+
return "; ".join(parts) or "Invalid request"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def error_from_response(response: httpx.Response) -> err.CubicError:
|
|
174
|
+
"""Map a non-2xx API response onto the SDK exception hierarchy.
|
|
175
|
+
|
|
176
|
+
Dispatch is by ``error_code`` first, HTTP status second — the API surfaces
|
|
177
|
+
e.g. insufficient credits under more than one status.
|
|
178
|
+
"""
|
|
179
|
+
status = response.status_code
|
|
180
|
+
request_id = response.headers.get("X-Request-ID")
|
|
181
|
+
try:
|
|
182
|
+
body = response.json()
|
|
183
|
+
except Exception:
|
|
184
|
+
body = None
|
|
185
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
186
|
+
error_code = body.get("error_code") if isinstance(body, dict) else None
|
|
187
|
+
common: dict[str, Any] = {
|
|
188
|
+
"error_code": error_code,
|
|
189
|
+
"status_code": status,
|
|
190
|
+
"request_id": request_id,
|
|
191
|
+
"body": body,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Pydantic validation errors arrive as a list of field errors.
|
|
195
|
+
if isinstance(detail, list):
|
|
196
|
+
return err.InvalidRequestError(_flatten_validation_detail(detail), **common)
|
|
197
|
+
|
|
198
|
+
message = detail if isinstance(detail, str) else f"HTTP {status} from Cubic API"
|
|
199
|
+
|
|
200
|
+
if error_code == "insufficient_credits" or status == 402:
|
|
201
|
+
g = body if isinstance(body, dict) else {}
|
|
202
|
+
return err.InsufficientCreditsError(
|
|
203
|
+
message,
|
|
204
|
+
required=g.get("required"),
|
|
205
|
+
balance=g.get("balance"),
|
|
206
|
+
grace=g.get("grace"),
|
|
207
|
+
topup_allowed=g.get("topup_allowed"),
|
|
208
|
+
**common,
|
|
209
|
+
)
|
|
210
|
+
if status == 401:
|
|
211
|
+
return err.AuthenticationError(message, **common)
|
|
212
|
+
if status == 403:
|
|
213
|
+
return err.PermissionDeniedError(message, **common)
|
|
214
|
+
if status == 404:
|
|
215
|
+
if error_code == "version_not_found":
|
|
216
|
+
return err.VersionNotFoundError(message, **common)
|
|
217
|
+
if error_code in ("cube_not_found", "chain_not_found", "prompt_not_found"):
|
|
218
|
+
return err.CubeNotFoundError(
|
|
219
|
+
message + " (the ID may not exist, or your key may not own it — "
|
|
220
|
+
"marketplace cube definitions are not readable by subscribers)",
|
|
221
|
+
**common,
|
|
222
|
+
)
|
|
223
|
+
if error_code == "completion_not_found":
|
|
224
|
+
return err.CompletionNotFoundError(message, **common)
|
|
225
|
+
return err.NotFoundError(message, **common)
|
|
226
|
+
if status == 422:
|
|
227
|
+
return err.InvalidRequestError(message, **common)
|
|
228
|
+
if status == 429:
|
|
229
|
+
retry_after_header = response.headers.get("Retry-After")
|
|
230
|
+
try:
|
|
231
|
+
retry_after = float(retry_after_header) if retry_after_header else None
|
|
232
|
+
except ValueError:
|
|
233
|
+
retry_after = None
|
|
234
|
+
return err.RateLimitError(message, retry_after=retry_after, **common)
|
|
235
|
+
if status == 504:
|
|
236
|
+
# The deadline response is a CompletionResponse envelope, not the
|
|
237
|
+
# standard error shape — pull the human message out of attempt_errors.
|
|
238
|
+
if isinstance(body, dict):
|
|
239
|
+
for ae in body.get("attempt_errors") or []:
|
|
240
|
+
if isinstance(ae, dict) and ae.get("message"):
|
|
241
|
+
message = ae["message"]
|
|
242
|
+
common["error_code"] = ae.get("error_code")
|
|
243
|
+
break
|
|
244
|
+
return err.CompletionTimeoutError(message, **common)
|
|
245
|
+
if status >= 500:
|
|
246
|
+
return err.InternalServerError(message, **common)
|
|
247
|
+
return err.CubicError(message, **common)
|
cubic/_exceptions.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Exception hierarchy for the Cubic SDK.
|
|
2
|
+
|
|
3
|
+
Every exception carries ``error_code`` (the API's machine-readable code),
|
|
4
|
+
``status_code`` (HTTP, when the error came from a non-2xx response),
|
|
5
|
+
``request_id`` (the ``X-Request-ID`` correlation header — quote it in support
|
|
6
|
+
requests), and ``body`` (the raw parsed response body, when available).
|
|
7
|
+
|
|
8
|
+
Pipeline failures — where the API returns HTTP 200 with ``status: "error"`` —
|
|
9
|
+
are raised as :class:`CompletionError` subclasses and additionally carry the
|
|
10
|
+
parsed result object on ``.result``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CubicError(Exception):
|
|
19
|
+
"""Base class for every error raised by the Cubic SDK."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
error_code: str | None = None,
|
|
26
|
+
status_code: int | None = None,
|
|
27
|
+
request_id: str | None = None,
|
|
28
|
+
body: Any = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.message = message
|
|
32
|
+
self.error_code = error_code
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
self.body = body
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIConnectionError(CubicError):
|
|
39
|
+
"""The SDK could not reach the Cubic API (DNS, refused connection, TLS…)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class APITimeoutError(APIConnectionError):
|
|
43
|
+
"""The HTTP request timed out client-side before a response arrived."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthenticationError(CubicError):
|
|
47
|
+
"""401 — the API key is missing, invalid, revoked, or expired."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PermissionDeniedError(CubicError):
|
|
51
|
+
"""403 — e.g. ``marketplace_subscription_required`` or ``override_forbidden``."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFoundError(CubicError):
|
|
55
|
+
"""404 — the referenced resource does not exist (or is not yours)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CubeNotFoundError(NotFoundError):
|
|
59
|
+
"""The cube/polycube ID could not be resolved.
|
|
60
|
+
|
|
61
|
+
The API deliberately returns the same response for an unknown ID and for a
|
|
62
|
+
cube your key does not own, so foreign IDs cannot be probed. Note that
|
|
63
|
+
marketplace subscribers can *run* a listed cube but cannot read its
|
|
64
|
+
definition, and ``cubes.retrieve`` does not (yet) serve polycube IDs.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class VersionNotFoundError(CubeNotFoundError):
|
|
69
|
+
"""The cube exists but the pinned ``version`` does not."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CompletionNotFoundError(NotFoundError):
|
|
73
|
+
"""No completion record exists for the given ``request_id``."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ModelNotFoundError(NotFoundError):
|
|
77
|
+
"""No catalog model matches the given name (client-side lookup against
|
|
78
|
+
``models.list()``). The message includes close-match suggestions."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InvalidRequestError(CubicError):
|
|
82
|
+
"""422 — the request body failed validation (unknown fields, bad types,
|
|
83
|
+
out-of-range parameters, or fields not applicable to a polycube)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InsufficientCreditsError(CubicError):
|
|
87
|
+
"""The credit gate rejected the request. Not retryable — top up first."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
message: str,
|
|
92
|
+
*,
|
|
93
|
+
required: int | None = None,
|
|
94
|
+
balance: int | None = None,
|
|
95
|
+
grace: int | None = None,
|
|
96
|
+
topup_allowed: bool | None = None,
|
|
97
|
+
**kwargs: Any,
|
|
98
|
+
) -> None:
|
|
99
|
+
super().__init__(message, **kwargs)
|
|
100
|
+
self.required = required
|
|
101
|
+
self.balance = balance
|
|
102
|
+
self.grace = grace
|
|
103
|
+
self.topup_allowed = topup_allowed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RateLimitError(CubicError):
|
|
107
|
+
"""429 — the server is at capacity or a quota was exceeded.
|
|
108
|
+
|
|
109
|
+
Capacity 429s are retried automatically; if you see this, retries were
|
|
110
|
+
exhausted. ``retry_after`` echoes the server's ``Retry-After`` header.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
|
|
114
|
+
super().__init__(message, **kwargs)
|
|
115
|
+
self.retry_after = retry_after
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CompletionTimeoutError(CubicError):
|
|
119
|
+
"""504 — the completion exceeded the server's execution deadline."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class InternalServerError(CubicError):
|
|
123
|
+
"""5xx — an unexpected server-side failure."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WaitTimeoutError(CubicError):
|
|
127
|
+
"""``wait()`` gave up before the queued completion's result was persisted.
|
|
128
|
+
|
|
129
|
+
The run may still complete server-side — the ``request_id`` stays valid for
|
|
130
|
+
``completions.retrieve`` and the callback delivery is unaffected.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WebhookSignatureError(CubicError):
|
|
135
|
+
"""The callback payload's ``X-Maxwell-Signature`` header is missing or does
|
|
136
|
+
not match the payload — treat the delivery as unauthenticated."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CompletionError(CubicError):
|
|
140
|
+
"""The request was accepted but the completion pipeline failed
|
|
141
|
+
(HTTP 200 with ``status: "error"``).
|
|
142
|
+
|
|
143
|
+
``result`` holds the fully parsed response — including ``attempt_errors``
|
|
144
|
+
and, for polycubes, per-node ``segments`` — for inspection.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
message: str,
|
|
150
|
+
*,
|
|
151
|
+
result: Any = None,
|
|
152
|
+
attempt_errors: list[Any] | None = None,
|
|
153
|
+
**kwargs: Any,
|
|
154
|
+
) -> None:
|
|
155
|
+
super().__init__(message, **kwargs)
|
|
156
|
+
self.result = result
|
|
157
|
+
self.attempt_errors = attempt_errors or []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MissingVariableError(CompletionError):
|
|
161
|
+
"""A required template variable was not provided (or failed coercion)."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, message: str, *, variable_name: str | None = None, **kwargs: Any) -> None:
|
|
164
|
+
super().__init__(message, **kwargs)
|
|
165
|
+
self.variable_name = variable_name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ProviderError(CompletionError):
|
|
169
|
+
"""Every model attempt failed at the provider stage (rate limits, outages,
|
|
170
|
+
circuit breaker open, missing provider credential…). Check
|
|
171
|
+
``attempt_errors`` for the per-attempt detail, including ``is_retryable``."""
|
cubic/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
cubic/py.typed
ADDED
|
File without changes
|
|
File without changes
|