commoncompute 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.
@@ -0,0 +1,86 @@
1
+ """Common Compute — official Python SDK.
2
+
3
+ Thin, typed wrapper around the Common Compute HTTP API. Ships an OpenAI-
4
+ compatible path for drop-in migration plus first-class helpers for the
5
+ native job API. See https://commoncompute.ai/docs for the full reference.
6
+
7
+ Quickstart::
8
+
9
+ import commoncompute as cc
10
+
11
+ client = cc.Client(api_key="cc_live_...")
12
+ print(client.balance())
13
+
14
+ # OpenAI-compatible chat
15
+ reply = client.chat.create(
16
+ model="qwen-2.5-7b-instruct",
17
+ messages=[{"role": "user", "content": "hi"}],
18
+ )
19
+ print(reply["choices"][0]["message"]["content"])
20
+
21
+ # Native job submission
22
+ job = client.jobs.submit(
23
+ workload_id="coreml_embed",
24
+ payload={"inputs": ["hello world"]},
25
+ )
26
+ result = client.jobs.wait(job["id"])
27
+
28
+ Streaming chat::
29
+
30
+ for chunk in client.chat.stream(model="qwen-2.5-7b-instruct",
31
+ messages=[{"role": "user", "content": "hi"}]):
32
+ delta = chunk["choices"][0].get("delta", {}).get("content", "")
33
+ print(delta, end="", flush=True)
34
+ """
35
+
36
+ # MUST come before any submodule imports — _transport.py reads
37
+ # `commoncompute.__version__` at import time for the User-Agent header,
38
+ # and the previous ordering (`__version__` defined at the bottom) made
39
+ # every clean install of the SDK fail with a circular ImportError.
40
+ __version__ = "0.1.0"
41
+
42
+ from .client import Client, AsyncClient
43
+ from .errors import (
44
+ CommonComputeError,
45
+ AuthenticationError,
46
+ NotFoundError,
47
+ RateLimitError,
48
+ InsufficientFundsError,
49
+ InsufficientCreditsError, # legacy alias of InsufficientFundsError
50
+ BadRequestError,
51
+ ValidationError,
52
+ UnsupportedFormatError,
53
+ NetworkError,
54
+ JobTimeoutError,
55
+ APIError,
56
+ )
57
+ from .models import Job, Quote, Receipt, TaskResult, AccountTier, SpendSummary
58
+ from .connect import connect, login
59
+ from . import aws_batch # noqa: E402 (intentional re-export)
60
+
61
+ __all__ = [
62
+ "Client",
63
+ "AsyncClient",
64
+ "connect",
65
+ "login",
66
+ "CommonComputeError",
67
+ "AuthenticationError",
68
+ "NotFoundError",
69
+ "RateLimitError",
70
+ "InsufficientFundsError",
71
+ "InsufficientCreditsError",
72
+ "BadRequestError",
73
+ "ValidationError",
74
+ "UnsupportedFormatError",
75
+ "NetworkError",
76
+ "JobTimeoutError",
77
+ "APIError",
78
+ "Job",
79
+ "Quote",
80
+ "Receipt",
81
+ "TaskResult",
82
+ "AccountTier",
83
+ "SpendSummary",
84
+ "aws_batch",
85
+ "__version__",
86
+ ]
@@ -0,0 +1,60 @@
1
+ # @generated by packages/workloads/codegen/emit-sdk-workloads.ts — DO NOT EDIT.
2
+ # Regenerate: npx tsx packages/workloads/codegen/emit-sdk-workloads.ts
3
+ # Source of truth: packages/workloads (the shared catalog). CI fails if this
4
+ # file drifts from the catalog (see tests/unit/workloads-parity.test.ts).
5
+ from __future__ import annotations
6
+
7
+ # Every workload id in the catalog. Used to decide whether a jobDefinition head
8
+ # names a workload directly vs. a model.
9
+ KNOWN_WORKLOAD_IDS: frozenset[str] = frozenset({
10
+ "coreml_embed",
11
+ "whisper_ane",
12
+ "whisper_subtitle",
13
+ "accelerate_denoise",
14
+ "pyannote_diarize",
15
+ "audio_classify",
16
+ "avspeech_tts",
17
+ "apple_translate",
18
+ "bge_rerank",
19
+ "nl_langdetect",
20
+ "nl_ner",
21
+ "nl_sentiment",
22
+ "vision_ocr",
23
+ "vision_bgremove",
24
+ "vision_aesthetics",
25
+ "vision_face",
26
+ "vision_detect",
27
+ "vision_pose",
28
+ "vision_barcode",
29
+ "vision_segment",
30
+ "coreml_upscale",
31
+ "vt_transcode",
32
+ "videotoolbox_heic",
33
+ "av_thumbnail",
34
+ "vision_scene",
35
+ "mlx_image",
36
+ "xcode_build",
37
+ "xctest_runner",
38
+ "mlx_small_rag",
39
+ "mlx_llm",
40
+ "mlx_llm_long_ctx",
41
+ "coreml_vision",
42
+ "sd_mlx",
43
+ "mlx_finetune",
44
+ "pytorch_mps",
45
+ "blender_metal",
46
+ "cpu_bench",
47
+ })
48
+
49
+ # (regex source, workload_id), highest priority first. Compile with re.IGNORECASE.
50
+ IMAGE_PATTERNS: tuple[tuple[str, str], ...] = (
51
+ ("whisper", "whisper_ane"),
52
+ ("(embed|sentence-transformer|bge|e5-)", "coreml_embed"),
53
+ ("(stable-diffusion|sdxl|sd-|sd_)", "sd_mlx"),
54
+ ("(flux|mlx-image)", "mlx_image"),
55
+ ("(llama|mistral|qwen|mlx-llm|chat-llm)", "mlx_llm"),
56
+ ("(ffmpeg|transcode|videotoolbox|vt[-_])", "vt_transcode"),
57
+ ("(ocr|tesseract|textract)", "vision_ocr"),
58
+ ("(blender|cycles)", "blender_metal"),
59
+ ("(pytorch|torch)", "pytorch_mps"),
60
+ )
@@ -0,0 +1,359 @@
1
+ """Low-level HTTP transport shared by sync + async clients.
2
+
3
+ Thin wrapper around httpx that:
4
+ - injects ``Authorization: Bearer <api_key>`` and ``User-Agent``
5
+ - injects the optional ``X-CC-Org`` header for multi-workspace accounts
6
+ - re-raises non-2xx responses as typed SDK exceptions
7
+ - parses JSON once and surfaces the request-id for support
8
+ - handles Server-Sent Events streaming (``text/event-stream``)
9
+
10
+ Kept deliberately small so both ``Client`` and ``AsyncClient`` can share
11
+ the same contract without duplicating retry / parse logic.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import os
19
+ import random
20
+ import time
21
+ import uuid
22
+ from typing import Any, AsyncIterator, Iterator, Mapping, Optional
23
+
24
+ import httpx
25
+
26
+ from . import __version__
27
+ from .errors import CommonComputeError, NetworkError, _from_status
28
+
29
+ DEFAULT_BASE_URL = "https://api.commoncompute.ai"
30
+ DEFAULT_TIMEOUT = 60.0
31
+ DEFAULT_MAX_RETRIES = 2
32
+
33
+
34
+ def _key_from_config_files() -> Optional[str]:
35
+ """API-key fallback when CC_API_KEY isn't set.
36
+
37
+ Checks ``~/.commoncompute/config`` (documented location; either a bare
38
+ key or an ``api_key = ...`` line) and the legacy CLI location
39
+ ``~/.config/commoncompute/credentials``. Never logs the key.
40
+ """
41
+ import pathlib
42
+
43
+ candidates = [
44
+ pathlib.Path(os.environ.get("CC_CONFIG_DIR") or (pathlib.Path.home() / ".commoncompute")) / "config",
45
+ pathlib.Path.home() / ".config" / "commoncompute" / "credentials",
46
+ ]
47
+ for path in candidates:
48
+ try:
49
+ if not path.exists():
50
+ continue
51
+ for line in path.read_text().splitlines():
52
+ line = line.strip()
53
+ if not line or line.startswith("#"):
54
+ continue
55
+ if "=" in line:
56
+ k, _, v = line.partition("=")
57
+ if k.strip().lower() in ("api_key", "cc_api_key"):
58
+ return v.strip().strip('"').strip("'") or None
59
+ continue
60
+ return line
61
+ except OSError:
62
+ continue
63
+ return None
64
+
65
+ # Transient statuses worth retrying. 503 is retryable EXCEPT when the body
66
+ # carries code=no_capacity — that's the API's deterministic "no provider can
67
+ # run this workload right now" answer; re-submitting burns the server's
68
+ # grace window again for the same outcome.
69
+ _RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
70
+
71
+
72
+ def _is_retryable(response: httpx.Response) -> bool:
73
+ if response.status_code not in _RETRYABLE_STATUSES:
74
+ return False
75
+ if response.status_code == 503:
76
+ body = _parse_body(response)
77
+ if isinstance(body, dict):
78
+ err = body.get("error")
79
+ code = body.get("code") or (err.get("code") if isinstance(err, dict) else None)
80
+ if code == "no_capacity":
81
+ return False
82
+ return True
83
+
84
+
85
+ def _retry_delay(attempt: int, response: Optional[httpx.Response]) -> float:
86
+ if response is not None:
87
+ ra = response.headers.get("retry-after")
88
+ if ra:
89
+ try:
90
+ return min(max(float(ra), 0.0), 30.0)
91
+ except ValueError:
92
+ pass
93
+ # 0.5s, 1.5s (+ jitter) — bounded so 3 attempts fit a 60s caller budget.
94
+ return 0.5 * (3**attempt) + random.random() * 0.25
95
+
96
+
97
+ def _build_headers(
98
+ api_key: Optional[str],
99
+ org: Optional[str],
100
+ extra: Optional[Mapping[str, str]] = None,
101
+ ) -> dict[str, str]:
102
+ headers: dict[str, str] = {
103
+ "User-Agent": f"commoncompute-python/{__version__}",
104
+ "Accept": "application/json",
105
+ }
106
+ if api_key:
107
+ headers["Authorization"] = f"Bearer {api_key}"
108
+ if org:
109
+ headers["X-CC-Org"] = org
110
+ if extra:
111
+ for k, v in extra.items():
112
+ if v is not None:
113
+ headers[k] = str(v)
114
+ return headers
115
+
116
+
117
+ def _parse_body(response: httpx.Response) -> Any:
118
+ """Best-effort JSON parse; fall back to raw text for non-JSON bodies."""
119
+ ct = response.headers.get("content-type", "")
120
+ if "application/json" in ct or response.text.startswith(("{", "[")):
121
+ try:
122
+ return response.json()
123
+ except (json.JSONDecodeError, ValueError):
124
+ return {"raw": response.text}
125
+ return response.text
126
+
127
+
128
+ def _raise_for(response: httpx.Response) -> None:
129
+ if response.is_success:
130
+ return
131
+ body = _parse_body(response)
132
+ code = None
133
+ message = f"HTTP {response.status_code}"
134
+ if isinstance(body, dict):
135
+ code = body.get("code") or body.get("error_code")
136
+ err = body.get("error")
137
+ if isinstance(err, dict):
138
+ # OpenAI-shaped error body: {"error": {"message", "type", "code"}}
139
+ message = err.get("message") or body.get("message") or message
140
+ code = code or err.get("code")
141
+ else:
142
+ message = err or body.get("message") or message
143
+ request_id = response.headers.get("x-request-id") or response.headers.get("cf-ray")
144
+ raise _from_status(response.status_code, str(message), code=code, request_id=request_id, body=body)
145
+
146
+
147
+ class _BaseTransport:
148
+ """Shared config between sync and async transports."""
149
+
150
+ def __init__(
151
+ self,
152
+ *,
153
+ api_key: Optional[str] = None,
154
+ base_url: Optional[str] = None,
155
+ org: Optional[str] = None,
156
+ timeout: float = DEFAULT_TIMEOUT,
157
+ ) -> None:
158
+ resolved_key = api_key or os.environ.get("CC_API_KEY") or _key_from_config_files()
159
+ if not resolved_key:
160
+ raise CommonComputeError(
161
+ "No API key. Run commoncompute.connect() (opens the browser, "
162
+ "no copy-paste) or `cc login` from a terminal — or pass "
163
+ "api_key=... / set CC_API_KEY.",
164
+ status_code=None,
165
+ code="missing_api_key",
166
+ )
167
+ self._api_key = resolved_key
168
+ self._base_url = (base_url or os.environ.get("CC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
169
+ self._org = org or os.environ.get("CC_ORG")
170
+ self._timeout = timeout
171
+
172
+ def _url(self, path: str) -> str:
173
+ if path.startswith(("http://", "https://")):
174
+ return path
175
+ if not path.startswith("/"):
176
+ path = "/" + path
177
+ return self._base_url + path
178
+
179
+
180
+ class SyncTransport(_BaseTransport):
181
+ """Synchronous HTTP client, used by ``Client``."""
182
+
183
+ def __init__(self, **kwargs: Any) -> None:
184
+ super().__init__(**kwargs)
185
+ self._client = httpx.Client(timeout=self._timeout)
186
+
187
+ def close(self) -> None:
188
+ self._client.close()
189
+
190
+ def __enter__(self) -> "SyncTransport":
191
+ return self
192
+
193
+ def __exit__(self, *exc_info: Any) -> None:
194
+ self.close()
195
+
196
+ def request(
197
+ self,
198
+ method: str,
199
+ path: str,
200
+ *,
201
+ params: Optional[Mapping[str, Any]] = None,
202
+ json_body: Any = None,
203
+ data: Any = None,
204
+ files: Any = None,
205
+ headers: Optional[Mapping[str, str]] = None,
206
+ ) -> Any:
207
+ hdrs = _build_headers(self._api_key, self._org, headers)
208
+ # Fresh Idempotency-Key per logical call makes retried POSTs replay
209
+ # the original submission server-side instead of double-charging.
210
+ if method.upper() == "POST" and "Idempotency-Key" not in hdrs:
211
+ hdrs["Idempotency-Key"] = f"idem_{uuid.uuid4()}"
212
+ # File handles can't be rewound safely after a send — no retries then.
213
+ attempts = 1 if files is not None else DEFAULT_MAX_RETRIES + 1
214
+ last_exc: Optional[Exception] = None
215
+ for attempt in range(attempts):
216
+ try:
217
+ resp = self._client.request(
218
+ method,
219
+ self._url(path),
220
+ params=params,
221
+ json=json_body,
222
+ data=data,
223
+ files=files,
224
+ headers=hdrs,
225
+ )
226
+ except httpx.TransportError as exc:
227
+ last_exc = exc
228
+ if attempt < attempts - 1:
229
+ time.sleep(_retry_delay(attempt, None))
230
+ continue
231
+ raise NetworkError(str(exc), code="network_error") from exc
232
+ if not resp.is_success and attempt < attempts - 1 and _is_retryable(resp):
233
+ time.sleep(_retry_delay(attempt, resp))
234
+ continue
235
+ _raise_for(resp)
236
+ return _parse_body(resp)
237
+ if isinstance(last_exc, httpx.TransportError):
238
+ raise NetworkError(str(last_exc), code="network_error") from last_exc
239
+ raise last_exc if last_exc else CommonComputeError("request failed", status_code=None, code="request_failed")
240
+
241
+ def stream_sse(
242
+ self,
243
+ method: str,
244
+ path: str,
245
+ *,
246
+ json_body: Any = None,
247
+ headers: Optional[Mapping[str, str]] = None,
248
+ ) -> Iterator[dict[str, Any]]:
249
+ """Yield parsed JSON event payloads from a text/event-stream response."""
250
+ hdrs = _build_headers(self._api_key, self._org, headers)
251
+ hdrs["Accept"] = "text/event-stream"
252
+ with self._client.stream(
253
+ method,
254
+ self._url(path),
255
+ json=json_body,
256
+ headers=hdrs,
257
+ ) as resp:
258
+ if not resp.is_success:
259
+ resp.read()
260
+ _raise_for(resp)
261
+ for line in resp.iter_lines():
262
+ if not line or not line.startswith("data:"):
263
+ continue
264
+ payload = line[5:].strip()
265
+ if payload == "[DONE]":
266
+ return
267
+ try:
268
+ yield json.loads(payload)
269
+ except json.JSONDecodeError:
270
+ continue
271
+
272
+
273
+ class AsyncTransport(_BaseTransport):
274
+ """Asynchronous HTTP client, used by ``AsyncClient``."""
275
+
276
+ def __init__(self, **kwargs: Any) -> None:
277
+ super().__init__(**kwargs)
278
+ self._client = httpx.AsyncClient(timeout=self._timeout)
279
+
280
+ async def aclose(self) -> None:
281
+ await self._client.aclose()
282
+
283
+ async def __aenter__(self) -> "AsyncTransport":
284
+ return self
285
+
286
+ async def __aexit__(self, *exc_info: Any) -> None:
287
+ await self.aclose()
288
+
289
+ async def request(
290
+ self,
291
+ method: str,
292
+ path: str,
293
+ *,
294
+ params: Optional[Mapping[str, Any]] = None,
295
+ json_body: Any = None,
296
+ data: Any = None,
297
+ files: Any = None,
298
+ headers: Optional[Mapping[str, str]] = None,
299
+ ) -> Any:
300
+ hdrs = _build_headers(self._api_key, self._org, headers)
301
+ if method.upper() == "POST" and "Idempotency-Key" not in hdrs:
302
+ hdrs["Idempotency-Key"] = f"idem_{uuid.uuid4()}"
303
+ attempts = 1 if files is not None else DEFAULT_MAX_RETRIES + 1
304
+ last_exc: Optional[Exception] = None
305
+ for attempt in range(attempts):
306
+ try:
307
+ resp = await self._client.request(
308
+ method,
309
+ self._url(path),
310
+ params=params,
311
+ json=json_body,
312
+ data=data,
313
+ files=files,
314
+ headers=hdrs,
315
+ )
316
+ except httpx.TransportError as exc:
317
+ last_exc = exc
318
+ if attempt < attempts - 1:
319
+ await asyncio.sleep(_retry_delay(attempt, None))
320
+ continue
321
+ raise NetworkError(str(exc), code="network_error") from exc
322
+ if not resp.is_success and attempt < attempts - 1 and _is_retryable(resp):
323
+ await asyncio.sleep(_retry_delay(attempt, resp))
324
+ continue
325
+ _raise_for(resp)
326
+ return _parse_body(resp)
327
+ if isinstance(last_exc, httpx.TransportError):
328
+ raise NetworkError(str(last_exc), code="network_error") from last_exc
329
+ raise last_exc if last_exc else CommonComputeError("request failed", status_code=None, code="request_failed")
330
+
331
+ async def stream_sse(
332
+ self,
333
+ method: str,
334
+ path: str,
335
+ *,
336
+ json_body: Any = None,
337
+ headers: Optional[Mapping[str, str]] = None,
338
+ ) -> AsyncIterator[dict[str, Any]]:
339
+ hdrs = _build_headers(self._api_key, self._org, headers)
340
+ hdrs["Accept"] = "text/event-stream"
341
+ async with self._client.stream(
342
+ method,
343
+ self._url(path),
344
+ json=json_body,
345
+ headers=hdrs,
346
+ ) as resp:
347
+ if not resp.is_success:
348
+ await resp.aread()
349
+ _raise_for(resp)
350
+ async for line in resp.aiter_lines():
351
+ if not line or not line.startswith("data:"):
352
+ continue
353
+ payload = line[5:].strip()
354
+ if payload == "[DONE]":
355
+ return
356
+ try:
357
+ yield json.loads(payload)
358
+ except json.JSONDecodeError:
359
+ continue