meshapi 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.
- meshapi/__init__.py +251 -0
- meshapi/_errors.py +94 -0
- meshapi/_http.py +444 -0
- meshapi/_types.py +573 -0
- meshapi/resources/__init__.py +1 -0
- meshapi/resources/batches.py +60 -0
- meshapi/resources/chat.py +63 -0
- meshapi/resources/compare.py +43 -0
- meshapi/resources/embeddings.py +24 -0
- meshapi/resources/files.py +44 -0
- meshapi/resources/models.py +48 -0
- meshapi/resources/responses.py +43 -0
- meshapi/resources/templates.py +60 -0
- meshapi-0.1.0.dist-info/METADATA +519 -0
- meshapi-0.1.0.dist-info/RECORD +16 -0
- meshapi-0.1.0.dist-info/WHEEL +4 -0
meshapi/_http.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""HTTP client (sync + async) with retry/backoff and SSE parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, AsyncIterator, Dict, Iterator, Optional, Set, Type, TypeVar, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._errors import MeshAPIError
|
|
16
|
+
from ._types import ChatCompletionChunk
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
_RETRY_STATUS_CODES: Set[int] = {429, 502, 503, 504}
|
|
21
|
+
_DEFAULT_TIMEOUT = 60.0
|
|
22
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
23
|
+
_BACKOFF_BASE_MS = 500
|
|
24
|
+
_BACKOFF_MAX_MS = 30_000
|
|
25
|
+
|
|
26
|
+
_SDK_VERSION_HEADER = "X-MeshAPI-SDK"
|
|
27
|
+
_SDK_VERSION_VALUE = "python/0.1.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MeshAPIConfig:
|
|
32
|
+
base_url: str
|
|
33
|
+
token: str
|
|
34
|
+
timeout: float = _DEFAULT_TIMEOUT
|
|
35
|
+
max_retries: int = _DEFAULT_MAX_RETRIES
|
|
36
|
+
httpx_client: Optional[httpx.Client] = field(default=None, repr=False)
|
|
37
|
+
async_httpx_client: Optional[httpx.AsyncClient] = field(default=None, repr=False)
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
self.base_url = self.base_url.rstrip("/")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# SSE helpers
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_DONE_SENTINEL = object() # returned by _try_parse_sse_frame when [DONE] is seen
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_sse_data(frame: str) -> Optional[str]:
|
|
52
|
+
data_lines = []
|
|
53
|
+
for line in frame.splitlines():
|
|
54
|
+
if line.startswith("data: "):
|
|
55
|
+
data_lines.append(line[len("data: "):])
|
|
56
|
+
if not data_lines:
|
|
57
|
+
return None
|
|
58
|
+
return "\n".join(data_lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _try_parse_sse_frame(frame: str) -> "Optional[Union[ChatCompletionChunk, object]]":
|
|
62
|
+
"""Parse one SSE frame string.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
ChatCompletionChunk on success
|
|
66
|
+
_DONE_SENTINEL when [DONE] is received (caller should stop iteration)
|
|
67
|
+
None for empty / comment-only frames
|
|
68
|
+
Raises:
|
|
69
|
+
MeshAPIError on mid-stream error frames
|
|
70
|
+
"""
|
|
71
|
+
data_line = _extract_sse_data(frame)
|
|
72
|
+
if data_line is None or data_line.strip() == "":
|
|
73
|
+
return None
|
|
74
|
+
if data_line.strip() == "[DONE]":
|
|
75
|
+
return _DONE_SENTINEL
|
|
76
|
+
try:
|
|
77
|
+
parsed = json.loads(data_line)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
if isinstance(parsed, dict) and "error" in parsed:
|
|
82
|
+
err = parsed["error"]
|
|
83
|
+
raise MeshAPIError(
|
|
84
|
+
err.get("message", "upstream error"),
|
|
85
|
+
status=0,
|
|
86
|
+
error_code=err.get("code", "upstream_error"),
|
|
87
|
+
request_id="",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return ChatCompletionChunk.model_validate(parsed)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _try_parse_json_sse_frame(frame: str, model_cls: Type[T]) -> Optional[Union[T, object]]:
|
|
94
|
+
data_line = _extract_sse_data(frame)
|
|
95
|
+
if data_line is None or data_line.strip() == "":
|
|
96
|
+
return None
|
|
97
|
+
if data_line.strip() == "[DONE]":
|
|
98
|
+
return _DONE_SENTINEL
|
|
99
|
+
try:
|
|
100
|
+
parsed = json.loads(data_line)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
return None
|
|
103
|
+
if isinstance(parsed, dict) and "error" in parsed:
|
|
104
|
+
err = parsed["error"]
|
|
105
|
+
raise MeshAPIError(
|
|
106
|
+
err.get("message", "upstream error"),
|
|
107
|
+
status=0,
|
|
108
|
+
error_code=err.get("code", "upstream_error"),
|
|
109
|
+
request_id="",
|
|
110
|
+
)
|
|
111
|
+
return model_cls.model_validate(parsed)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _iter_sse(response: httpx.Response) -> Iterator[ChatCompletionChunk]:
|
|
115
|
+
"""Sync SSE iterator with remainder-buffer handling. Stops on [DONE]."""
|
|
116
|
+
remainder = ""
|
|
117
|
+
try:
|
|
118
|
+
for raw_bytes in response.iter_bytes():
|
|
119
|
+
try:
|
|
120
|
+
remainder += raw_bytes.decode("utf-8", errors="replace")
|
|
121
|
+
except Exception:
|
|
122
|
+
continue
|
|
123
|
+
frames = remainder.split("\n\n")
|
|
124
|
+
remainder = frames.pop()
|
|
125
|
+
for frame in frames:
|
|
126
|
+
if not frame.strip():
|
|
127
|
+
continue
|
|
128
|
+
result = _try_parse_sse_frame(frame)
|
|
129
|
+
if result is _DONE_SENTINEL:
|
|
130
|
+
return
|
|
131
|
+
if result is not None:
|
|
132
|
+
yield result # type: ignore[misc]
|
|
133
|
+
except httpx.RemoteProtocolError as exc:
|
|
134
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
135
|
+
except httpx.StreamError as exc:
|
|
136
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _iter_json_sse(response: httpx.Response, model_cls: Type[T]) -> Iterator[T]:
|
|
140
|
+
remainder = ""
|
|
141
|
+
try:
|
|
142
|
+
for raw_bytes in response.iter_bytes():
|
|
143
|
+
try:
|
|
144
|
+
remainder += raw_bytes.decode("utf-8", errors="replace")
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
frames = remainder.split("\n\n")
|
|
148
|
+
remainder = frames.pop()
|
|
149
|
+
for frame in frames:
|
|
150
|
+
if not frame.strip():
|
|
151
|
+
continue
|
|
152
|
+
result = _try_parse_json_sse_frame(frame, model_cls)
|
|
153
|
+
if result is _DONE_SENTINEL:
|
|
154
|
+
return
|
|
155
|
+
if result is not None:
|
|
156
|
+
yield result
|
|
157
|
+
except httpx.RemoteProtocolError as exc:
|
|
158
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
159
|
+
except httpx.StreamError as exc:
|
|
160
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _aiter_sse(response: httpx.Response) -> AsyncIterator[ChatCompletionChunk]:
|
|
164
|
+
"""Async SSE iterator with remainder-buffer handling. Stops on [DONE]."""
|
|
165
|
+
remainder = ""
|
|
166
|
+
try:
|
|
167
|
+
async for raw_bytes in response.aiter_bytes():
|
|
168
|
+
try:
|
|
169
|
+
remainder += raw_bytes.decode("utf-8", errors="replace")
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
frames = remainder.split("\n\n")
|
|
173
|
+
remainder = frames.pop()
|
|
174
|
+
for frame in frames:
|
|
175
|
+
if not frame.strip():
|
|
176
|
+
continue
|
|
177
|
+
result = _try_parse_sse_frame(frame)
|
|
178
|
+
if result is _DONE_SENTINEL:
|
|
179
|
+
return
|
|
180
|
+
if result is not None:
|
|
181
|
+
yield result # type: ignore[misc]
|
|
182
|
+
except httpx.RemoteProtocolError as exc:
|
|
183
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
184
|
+
except httpx.StreamError as exc:
|
|
185
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _aiter_json_sse(response: httpx.Response, model_cls: Type[T]) -> AsyncIterator[T]:
|
|
189
|
+
remainder = ""
|
|
190
|
+
try:
|
|
191
|
+
async for raw_bytes in response.aiter_bytes():
|
|
192
|
+
try:
|
|
193
|
+
remainder += raw_bytes.decode("utf-8", errors="replace")
|
|
194
|
+
except Exception:
|
|
195
|
+
continue
|
|
196
|
+
frames = remainder.split("\n\n")
|
|
197
|
+
remainder = frames.pop()
|
|
198
|
+
for frame in frames:
|
|
199
|
+
if not frame.strip():
|
|
200
|
+
continue
|
|
201
|
+
result = _try_parse_json_sse_frame(frame, model_cls)
|
|
202
|
+
if result is _DONE_SENTINEL:
|
|
203
|
+
return
|
|
204
|
+
if result is not None:
|
|
205
|
+
yield result
|
|
206
|
+
except httpx.RemoteProtocolError as exc:
|
|
207
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
208
|
+
except httpx.StreamError as exc:
|
|
209
|
+
raise MeshAPIError.stream_interrupted(str(exc)) from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Retry helpers
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _compute_delay_s(attempt: int, retry_after: Optional[int]) -> float:
|
|
218
|
+
"""Exponential backoff with jitter, capped at _BACKOFF_MAX_MS."""
|
|
219
|
+
if retry_after is not None:
|
|
220
|
+
base = retry_after * 1000
|
|
221
|
+
else:
|
|
222
|
+
base = _BACKOFF_BASE_MS * (2 ** attempt)
|
|
223
|
+
capped = min(base, _BACKOFF_MAX_MS)
|
|
224
|
+
jittered = capped * (0.8 + random.random() * 0.4) # ±20%
|
|
225
|
+
return jittered / 1000.0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _retry_after_from_response(response: httpx.Response) -> Optional[int]:
|
|
229
|
+
val = response.headers.get("retry-after")
|
|
230
|
+
if val is not None:
|
|
231
|
+
try:
|
|
232
|
+
return int(math.ceil(float(val)))
|
|
233
|
+
except (ValueError, TypeError):
|
|
234
|
+
pass
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
239
|
+
if response.status_code < 400:
|
|
240
|
+
return
|
|
241
|
+
raise MeshAPIError.from_response(response)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Sync HTTP client
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class SyncHttpClient:
|
|
250
|
+
def __init__(self, config: MeshAPIConfig) -> None:
|
|
251
|
+
self._config = config
|
|
252
|
+
self._client = config.httpx_client or httpx.Client(
|
|
253
|
+
base_url=config.base_url,
|
|
254
|
+
timeout=config.timeout,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _headers(self) -> Dict[str, str]:
|
|
258
|
+
return {
|
|
259
|
+
"Authorization": f"Bearer {self._config.token}",
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
"Accept": "application/json",
|
|
262
|
+
_SDK_VERSION_HEADER: _SDK_VERSION_VALUE,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
def _request(
|
|
266
|
+
self,
|
|
267
|
+
method: str,
|
|
268
|
+
path: str,
|
|
269
|
+
*,
|
|
270
|
+
params: Optional[Dict[str, Any]] = None,
|
|
271
|
+
json_body: Optional[Any] = None,
|
|
272
|
+
stream: bool = False,
|
|
273
|
+
) -> httpx.Response:
|
|
274
|
+
kwargs: Dict[str, Any] = {
|
|
275
|
+
"headers": self._headers(),
|
|
276
|
+
"params": params,
|
|
277
|
+
}
|
|
278
|
+
if json_body is not None:
|
|
279
|
+
kwargs["json"] = json_body
|
|
280
|
+
|
|
281
|
+
for attempt in range(self._config.max_retries + 1):
|
|
282
|
+
if stream:
|
|
283
|
+
# Streaming: no retry, open the stream and return immediately
|
|
284
|
+
req = self._client.request(method, path, **kwargs)
|
|
285
|
+
_raise_for_status(req)
|
|
286
|
+
return req
|
|
287
|
+
|
|
288
|
+
response = self._client.request(method, path, **kwargs)
|
|
289
|
+
if response.status_code in _RETRY_STATUS_CODES and attempt < self._config.max_retries:
|
|
290
|
+
delay = _compute_delay_s(attempt, _retry_after_from_response(response))
|
|
291
|
+
time.sleep(delay)
|
|
292
|
+
continue
|
|
293
|
+
_raise_for_status(response)
|
|
294
|
+
return response
|
|
295
|
+
|
|
296
|
+
# Should never reach here
|
|
297
|
+
raise RuntimeError("unreachable")
|
|
298
|
+
|
|
299
|
+
def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
300
|
+
response = self._request("GET", path, params=params)
|
|
301
|
+
if response.status_code == 204:
|
|
302
|
+
return None
|
|
303
|
+
return response.json()
|
|
304
|
+
|
|
305
|
+
def post(self, path: str, body: Any) -> Any:
|
|
306
|
+
response = self._request("POST", path, json_body=body)
|
|
307
|
+
if response.status_code == 204:
|
|
308
|
+
return None
|
|
309
|
+
return response.json()
|
|
310
|
+
|
|
311
|
+
def patch(self, path: str, body: Any) -> Any:
|
|
312
|
+
response = self._request("PATCH", path, json_body=body)
|
|
313
|
+
if response.status_code == 204:
|
|
314
|
+
return None
|
|
315
|
+
return response.json()
|
|
316
|
+
|
|
317
|
+
def delete(self, path: str) -> None:
|
|
318
|
+
response = self._request("DELETE", path)
|
|
319
|
+
if response.status_code == 204:
|
|
320
|
+
return
|
|
321
|
+
response.json() # consume body; _raise_for_status already ran
|
|
322
|
+
|
|
323
|
+
def get_bytes(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> bytes:
|
|
324
|
+
response = self._request("GET", path, params=params)
|
|
325
|
+
return response.content
|
|
326
|
+
|
|
327
|
+
def stream(self, path: str, body: Any) -> Iterator[ChatCompletionChunk]:
|
|
328
|
+
with self._client.stream("POST", path, json=body, headers=self._headers()) as response:
|
|
329
|
+
_raise_for_status(response)
|
|
330
|
+
yield from _iter_sse(response)
|
|
331
|
+
|
|
332
|
+
def stream_json(self, path: str, body: Any, model_cls: Type[T]) -> Iterator[T]:
|
|
333
|
+
with self._client.stream("POST", path, json=body, headers=self._headers()) as response:
|
|
334
|
+
_raise_for_status(response)
|
|
335
|
+
yield from _iter_json_sse(response, model_cls)
|
|
336
|
+
|
|
337
|
+
def close(self) -> None:
|
|
338
|
+
self._client.close()
|
|
339
|
+
|
|
340
|
+
def __enter__(self) -> "SyncHttpClient":
|
|
341
|
+
return self
|
|
342
|
+
|
|
343
|
+
def __exit__(self, *args: Any) -> None:
|
|
344
|
+
self.close()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# Async HTTP client
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class AsyncHttpClient:
|
|
353
|
+
def __init__(self, config: MeshAPIConfig) -> None:
|
|
354
|
+
self._config = config
|
|
355
|
+
self._client = config.async_httpx_client or httpx.AsyncClient(
|
|
356
|
+
base_url=config.base_url,
|
|
357
|
+
timeout=config.timeout,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _headers(self) -> Dict[str, str]:
|
|
361
|
+
return {
|
|
362
|
+
"Authorization": f"Bearer {self._config.token}",
|
|
363
|
+
"Content-Type": "application/json",
|
|
364
|
+
"Accept": "application/json",
|
|
365
|
+
_SDK_VERSION_HEADER: _SDK_VERSION_VALUE,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async def _request(
|
|
369
|
+
self,
|
|
370
|
+
method: str,
|
|
371
|
+
path: str,
|
|
372
|
+
*,
|
|
373
|
+
params: Optional[Dict[str, Any]] = None,
|
|
374
|
+
json_body: Optional[Any] = None,
|
|
375
|
+
) -> httpx.Response:
|
|
376
|
+
kwargs: Dict[str, Any] = {
|
|
377
|
+
"headers": self._headers(),
|
|
378
|
+
"params": params,
|
|
379
|
+
}
|
|
380
|
+
if json_body is not None:
|
|
381
|
+
kwargs["json"] = json_body
|
|
382
|
+
|
|
383
|
+
for attempt in range(self._config.max_retries + 1):
|
|
384
|
+
response = await self._client.request(method, path, **kwargs)
|
|
385
|
+
if response.status_code in _RETRY_STATUS_CODES and attempt < self._config.max_retries:
|
|
386
|
+
delay = _compute_delay_s(attempt, _retry_after_from_response(response))
|
|
387
|
+
await asyncio.sleep(delay)
|
|
388
|
+
continue
|
|
389
|
+
_raise_for_status(response)
|
|
390
|
+
return response
|
|
391
|
+
|
|
392
|
+
raise RuntimeError("unreachable")
|
|
393
|
+
|
|
394
|
+
async def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
395
|
+
response = await self._request("GET", path, params=params)
|
|
396
|
+
if response.status_code == 204:
|
|
397
|
+
return None
|
|
398
|
+
return response.json()
|
|
399
|
+
|
|
400
|
+
async def post(self, path: str, body: Any) -> Any:
|
|
401
|
+
response = await self._request("POST", path, json_body=body)
|
|
402
|
+
if response.status_code == 204:
|
|
403
|
+
return None
|
|
404
|
+
return response.json()
|
|
405
|
+
|
|
406
|
+
async def patch(self, path: str, body: Any) -> Any:
|
|
407
|
+
response = await self._request("PATCH", path, json_body=body)
|
|
408
|
+
if response.status_code == 204:
|
|
409
|
+
return None
|
|
410
|
+
return response.json()
|
|
411
|
+
|
|
412
|
+
async def delete(self, path: str) -> None:
|
|
413
|
+
response = await self._request("DELETE", path)
|
|
414
|
+
if response.status_code == 204:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
async def get_bytes(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> bytes:
|
|
418
|
+
response = await self._request("GET", path, params=params)
|
|
419
|
+
return response.content
|
|
420
|
+
|
|
421
|
+
async def stream(self, path: str, body: Any) -> AsyncIterator[ChatCompletionChunk]:
|
|
422
|
+
async with self._client.stream(
|
|
423
|
+
"POST", path, json=body, headers=self._headers()
|
|
424
|
+
) as response:
|
|
425
|
+
_raise_for_status(response)
|
|
426
|
+
async for chunk in _aiter_sse(response):
|
|
427
|
+
yield chunk
|
|
428
|
+
|
|
429
|
+
async def stream_json(self, path: str, body: Any, model_cls: Type[T]) -> AsyncIterator[T]:
|
|
430
|
+
async with self._client.stream(
|
|
431
|
+
"POST", path, json=body, headers=self._headers()
|
|
432
|
+
) as response:
|
|
433
|
+
_raise_for_status(response)
|
|
434
|
+
async for item in _aiter_json_sse(response, model_cls):
|
|
435
|
+
yield item
|
|
436
|
+
|
|
437
|
+
async def aclose(self) -> None:
|
|
438
|
+
await self._client.aclose()
|
|
439
|
+
|
|
440
|
+
async def __aenter__(self) -> "AsyncHttpClient":
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
444
|
+
await self.aclose()
|