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.
- commoncompute/__init__.py +86 -0
- commoncompute/_generated_workloads.py +60 -0
- commoncompute/_transport.py +359 -0
- commoncompute/aws_batch.py +589 -0
- commoncompute/cli.py +474 -0
- commoncompute/client.py +591 -0
- commoncompute/compat/__init__.py +12 -0
- commoncompute/compat/openai.py +54 -0
- commoncompute/connect.py +140 -0
- commoncompute/errors.py +131 -0
- commoncompute/models.py +152 -0
- commoncompute/tasks.py +365 -0
- commoncompute-0.1.0.dist-info/METADATA +210 -0
- commoncompute-0.1.0.dist-info/RECORD +17 -0
- commoncompute-0.1.0.dist-info/WHEEL +4 -0
- commoncompute-0.1.0.dist-info/entry_points.txt +3 -0
- commoncompute-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|