dyadic 1.0.0__tar.gz

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.
dyadic-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: dyadic
3
+ Version: 1.0.0
4
+ Summary: The official Python SDK for the Dyadic API (Iris, OpenAI-compatible).
5
+ Author: Dyadic
6
+ License: MIT
7
+ Project-URL: Homepage, https://dyadic.app/developers/
8
+ Project-URL: Documentation, https://dyadic.app/developers/
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: httpx<1,>=0.25
15
+
16
+ # dyadic
17
+
18
+ The official Python SDK for the [Dyadic API](https://dyadic.app/developers/): use Iris
19
+ from Python with streaming, function calling, images and typed errors. Requests use your Dyadic credits.
20
+
21
+ ```sh
22
+ pip install dyadic
23
+ ```
24
+
25
+ ```python
26
+ from dyadic import Dyadic
27
+
28
+ client = Dyadic() # reads DYADIC_API_KEY
29
+ print(client.ask("Hello!", model="iris"))
30
+ ```
31
+
32
+ See https://dyadic.app/developers/ for the full guide.
dyadic-1.0.0/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # dyadic
2
+
3
+ The official Python SDK for the [Dyadic API](https://dyadic.app/developers/): use Iris
4
+ from Python with streaming, function calling, images and typed errors. Requests use your Dyadic credits.
5
+
6
+ ```sh
7
+ pip install dyadic
8
+ ```
9
+
10
+ ```python
11
+ from dyadic import Dyadic
12
+
13
+ client = Dyadic() # reads DYADIC_API_KEY
14
+ print(client.ask("Hello!", model="iris"))
15
+ ```
16
+
17
+ See https://dyadic.app/developers/ for the full guide.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dyadic"
7
+ version = "1.0.0"
8
+ description = "The official Python SDK for the Dyadic API (Iris, OpenAI-compatible)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Dyadic" }]
13
+ dependencies = ["httpx>=0.25,<1"]
14
+ classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Typing :: Typed"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://dyadic.app/developers/"
18
+ Documentation = "https://dyadic.app/developers/"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.setuptools.package-data]
24
+ dyadic = ["py.typed"]
dyadic-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """The official Python SDK for the Dyadic API.
2
+
3
+ from dyadic import Dyadic
4
+ client = Dyadic() # reads DYADIC_API_KEY
5
+ client.ask("Hello", model="iris")
6
+
7
+ Sync (``Dyadic``) and async (``AsyncDyadic``) clients share one shape:
8
+ ``models``, ``chat.completions``, ``completions``, ``responses``, ``embeddings``,
9
+ ``audio`` (speech, transcriptions, translations), ``moderations``, ``images``,
10
+ ``files``, ``batches`` and ``ask``.
11
+ Responses are ``APIObject`` values: dicts that also allow attribute access.
12
+ """
13
+ from ._client import AsyncDyadic, Dyadic
14
+ from ._errors import DyadicError
15
+ from ._types import APIObject, AsyncStream, Binary, Image, Stream
16
+
17
+ __version__ = "1.0.0"
18
+ __all__ = ["Dyadic", "AsyncDyadic", "DyadicError", "APIObject", "Stream", "AsyncStream", "Image", "Binary", "__version__"]
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import os
5
+ import random
6
+ import time
7
+ from typing import IO, Any, Dict, List, Optional, Tuple, Union
8
+
9
+ import httpx
10
+
11
+ from ._errors import DyadicError
12
+ from ._types import APIObject, AsyncStream, Binary, Image, Stream
13
+
14
+ DEFAULT_BASE_URL = "https://dyadic.app/api/openai/v1"
15
+ RETRY_STATUSES = {408, 409, 429, 500, 502, 503, 504}
16
+ Messages = List[Dict[str, Any]]
17
+ FileInput = Union[str, "os.PathLike[str]", bytes, IO[bytes], Tuple[str, Any], Tuple[str, Any, str]]
18
+
19
+
20
+ def _headers(api_key: str, app_name: Optional[str]) -> Dict[str, str]:
21
+ from . import __version__
22
+
23
+ headers = {"Authorization": f"Bearer {api_key}", "User-Agent": f"dyadic-python/{__version__}", "Accept": "application/json"}
24
+ if app_name:
25
+ headers["X-Title"] = app_name
26
+ return headers
27
+
28
+
29
+ def _delay(attempt: int, response: Optional[httpx.Response]) -> float:
30
+ after = response.headers.get("retry-after") if response is not None else None
31
+ if after and after.replace(".", "", 1).isdigit():
32
+ return min(float(after), 30.0)
33
+ return min(0.5 * 2 ** attempt, 8.0) + random.random() * 0.25
34
+
35
+
36
+ def _error(response: httpx.Response) -> DyadicError:
37
+ try:
38
+ data = response.json()
39
+ except ValueError:
40
+ data = {"error": {"message": response.text[:500] or f"HTTP {response.status_code}"}}
41
+ return DyadicError.from_response(response.status_code, data, response.headers.get("x-request-id", ""))
42
+
43
+
44
+ def _out_of_quota(response: httpx.Response) -> bool:
45
+ """A usage limit or empty credit balance won't clear by retrying."""
46
+ try:
47
+ return (response.json().get("error") or {}).get("type") == "insufficient_quota"
48
+ except (ValueError, AttributeError):
49
+ return False
50
+
51
+
52
+ def _clean(params: Dict[str, Any]) -> Dict[str, Any]:
53
+ return {k: v for k, v in params.items() if v is not None}
54
+
55
+
56
+ def _file(value: FileInput, default_name: str = "file") -> Tuple[str, bytes, str]:
57
+ """(filename, bytes, content type) from a path, bytes, an open file or a
58
+ (filename, content[, content type]) tuple."""
59
+ if isinstance(value, tuple):
60
+ name, content = value[0], value[1]
61
+ kind = value[2] if len(value) > 2 else ""
62
+ elif isinstance(value, (str, os.PathLike)):
63
+ with open(value, "rb") as handle:
64
+ name, content, kind = os.path.basename(os.fspath(value)), handle.read(), ""
65
+ elif isinstance(value, (bytes, bytearray)):
66
+ name, content, kind = default_name, bytes(value), ""
67
+ else:
68
+ name, content, kind = os.path.basename(str(getattr(value, "name", "") or default_name)), value.read(), ""
69
+ if isinstance(content, str):
70
+ content = content.encode()
71
+ elif not isinstance(content, (bytes, bytearray)):
72
+ content = content.read()
73
+ return name, bytes(content), kind or mimetypes.guess_type(name)[0] or "application/octet-stream"
74
+
75
+
76
+ def _form(params: Dict[str, Any]) -> Dict[str, Any]:
77
+ """Multipart text fields: lists repeat as name[] fields, numbers and
78
+ booleans become text."""
79
+ out: Dict[str, Any] = {}
80
+ for key, value in _clean(params).items():
81
+ if isinstance(value, (list, tuple)):
82
+ out[key if key.endswith("[]") else f"{key}[]"] = [str(v) for v in value]
83
+ elif isinstance(value, bool):
84
+ out[key] = "true" if value else "false"
85
+ else:
86
+ out[key] = str(value)
87
+ return out
88
+
89
+
90
+ def _result(response: httpx.Response, raw: bool) -> Any:
91
+ kind = response.headers.get("content-type", "").split(";")[0].strip().lower()
92
+ if not raw and (kind == "application/json" or kind.endswith("+json")):
93
+ return APIObject.wrap(response.json())
94
+ if not raw and kind.startswith("text/"):
95
+ return response.text
96
+ return Binary(response.content, kind, response.headers.get("x-request-id", ""))
97
+
98
+
99
+ class _Base:
100
+ def __init__(self, api_key: Optional[str], base_url: Optional[str], timeout: float, max_retries: int, app_name: Optional[str]) -> None:
101
+ self.api_key = api_key or os.environ.get("DYADIC_API_KEY") or ""
102
+ if not self.api_key:
103
+ raise DyadicError("No API key. Pass api_key= or set DYADIC_API_KEY. Create one at https://dyadic.app/workspace/?view=api.")
104
+ self.base_url = (base_url or os.environ.get("DYADIC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
105
+ self.timeout = timeout
106
+ self.max_retries = max_retries
107
+ self.app_name = app_name
108
+
109
+ def _services(self) -> None:
110
+ # Shared by both clients: each method returns the client's request()
111
+ # result, a value for Dyadic and an awaitable for AsyncDyadic.
112
+ self.responses = _Responses(self)
113
+ self.embeddings = _Embeddings(self)
114
+ self.audio = _Audio(self)
115
+ self.moderations = _Moderations(self)
116
+ self.images = _Images(self)
117
+ self.files = _Files(self)
118
+ self.batches = _Batches(self)
119
+
120
+
121
+ class Dyadic(_Base):
122
+ """The Dyadic API, synchronously.
123
+
124
+ ``Dyadic(api_key=None, base_url=None, timeout=600, max_retries=2,
125
+ app_name=None)``; the key defaults to ``DYADIC_API_KEY``. ``app_name`` is
126
+ shown as the app in your API activity log.
127
+ """
128
+
129
+ def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 600.0,
130
+ max_retries: int = 2, app_name: Optional[str] = None, http_client: Optional[httpx.Client] = None) -> None:
131
+ super().__init__(api_key, base_url, timeout, max_retries, app_name)
132
+ self._http = http_client or httpx.Client(timeout=httpx.Timeout(timeout, connect=10.0))
133
+ self.models = _Models(self)
134
+ self.chat = _Chat(self)
135
+ self.completions = _Completions(self)
136
+ self._services()
137
+
138
+ def request(self, method: str, path: str, body: Optional[dict] = None, *, stream: bool = False, files: Optional[dict] = None,
139
+ query: Optional[dict] = None, raw: bool = False, cast: Any = None) -> Any:
140
+ """Any API call: ``body`` is sent as JSON, or with ``files`` as
141
+ multipart form fields. Returns an APIObject (JSON), str (text) or
142
+ Binary (audio and file downloads), passed through ``cast`` if given."""
143
+ url = f"{self.base_url}{path}"
144
+ headers = _headers(self.api_key, self.app_name)
145
+ for attempt in range(self.max_retries + 1):
146
+ try:
147
+ if files is not None:
148
+ request = self._http.build_request(method, url, data=_form(body or {}), files=files, params=_clean(query or {}), headers=headers)
149
+ else:
150
+ request = self._http.build_request(method, url, json=body, params=_clean(query or {}), headers=headers)
151
+ response = self._http.send(request, stream=stream)
152
+ except httpx.TransportError as exc:
153
+ if attempt < self.max_retries:
154
+ time.sleep(_delay(attempt, None))
155
+ continue
156
+ raise DyadicError(f"Could not reach the Dyadic API: {exc}") from exc
157
+ if response.status_code < 400:
158
+ result = Stream(response) if stream else _result(response, raw)
159
+ return cast(result) if cast else result
160
+ if stream:
161
+ response.read()
162
+ if response.status_code in RETRY_STATUSES and attempt < self.max_retries and not _out_of_quota(response):
163
+ response.close()
164
+ time.sleep(_delay(attempt, response))
165
+ continue
166
+ raise _error(response)
167
+ raise DyadicError("The request could not be completed.")
168
+
169
+ def ask(self, prompt: str, *, model: str = "iris", system: Optional[str] = None, **params: Any) -> str:
170
+ """One question, one answer: the reply's text."""
171
+ messages: Messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
172
+ return self.chat.completions.create(model=model, messages=messages, **params).choices[0].message.content or ""
173
+
174
+ def openapi(self) -> APIObject:
175
+ """The API's OpenAPI description."""
176
+ return self.request("GET", "/openapi.json")
177
+
178
+ def close(self) -> None:
179
+ self._http.close()
180
+
181
+ def __enter__(self) -> "Dyadic":
182
+ return self
183
+
184
+ def __exit__(self, *exc: Any) -> None:
185
+ self.close()
186
+
187
+
188
+ class AsyncDyadic(_Base):
189
+ """The Dyadic API, asynchronously (same methods, awaited)."""
190
+
191
+ def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 600.0,
192
+ max_retries: int = 2, app_name: Optional[str] = None, http_client: Optional[httpx.AsyncClient] = None) -> None:
193
+ super().__init__(api_key, base_url, timeout, max_retries, app_name)
194
+ self._http = http_client or httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=10.0))
195
+ self.models = _AsyncModels(self)
196
+ self.chat = _AsyncChat(self)
197
+ self.completions = _AsyncCompletions(self)
198
+ self._services()
199
+
200
+ async def request(self, method: str, path: str, body: Optional[dict] = None, *, stream: bool = False, files: Optional[dict] = None,
201
+ query: Optional[dict] = None, raw: bool = False, cast: Any = None) -> Any:
202
+ import asyncio
203
+
204
+ url = f"{self.base_url}{path}"
205
+ headers = _headers(self.api_key, self.app_name)
206
+ for attempt in range(self.max_retries + 1):
207
+ try:
208
+ if files is not None:
209
+ request = self._http.build_request(method, url, data=_form(body or {}), files=files, params=_clean(query or {}), headers=headers)
210
+ else:
211
+ request = self._http.build_request(method, url, json=body, params=_clean(query or {}), headers=headers)
212
+ response = await self._http.send(request, stream=stream)
213
+ except httpx.TransportError as exc:
214
+ if attempt < self.max_retries:
215
+ await asyncio.sleep(_delay(attempt, None))
216
+ continue
217
+ raise DyadicError(f"Could not reach the Dyadic API: {exc}") from exc
218
+ if response.status_code < 400:
219
+ result = AsyncStream(response) if stream else _result(response, raw)
220
+ return cast(result) if cast else result
221
+ if stream:
222
+ await response.aread()
223
+ if response.status_code in RETRY_STATUSES and attempt < self.max_retries and not _out_of_quota(response):
224
+ await response.aclose()
225
+ await asyncio.sleep(_delay(attempt, response))
226
+ continue
227
+ raise _error(response)
228
+ raise DyadicError("The request could not be completed.")
229
+
230
+ async def ask(self, prompt: str, *, model: str = "iris", system: Optional[str] = None, **params: Any) -> str:
231
+ messages: Messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
232
+ reply = await self.chat.completions.create(model=model, messages=messages, **params)
233
+ return reply.choices[0].message.content or ""
234
+
235
+ async def openapi(self) -> APIObject:
236
+ return await self.request("GET", "/openapi.json")
237
+
238
+ async def close(self) -> None:
239
+ await self._http.aclose()
240
+
241
+ async def __aenter__(self) -> "AsyncDyadic":
242
+ return self
243
+
244
+ async def __aexit__(self, *exc: Any) -> None:
245
+ await self.close()
246
+
247
+
248
+ def _stream_body(params: Dict[str, Any], stream: bool) -> Dict[str, Any]:
249
+ body = _clean(params)
250
+ if stream:
251
+ body["stream"] = True
252
+ body.setdefault("stream_options", {"include_usage": True})
253
+ return body
254
+
255
+
256
+ class _Models:
257
+ def __init__(self, client: Dyadic) -> None:
258
+ self._client = client
259
+
260
+ def list(self) -> List[APIObject]:
261
+ return self._client.request("GET", "/models").data
262
+
263
+ def retrieve(self, model: str) -> APIObject:
264
+ return self._client.request("GET", f"/models/{model}")
265
+
266
+
267
+ class _ChatCompletions:
268
+ def __init__(self, client: Dyadic) -> None:
269
+ self._client = client
270
+
271
+ def create(self, *, model: str, messages: Messages, stream: bool = False, **params: Any) -> Union[APIObject, Stream]:
272
+ """POST /chat/completions. Any OpenAI field may be passed (tools,
273
+ response_format, top_p, seed...). With ``stream=True`` returns a Stream."""
274
+ body = _stream_body({"model": model, "messages": messages, **params}, stream)
275
+ return self._client.request("POST", "/chat/completions", body, stream=stream)
276
+
277
+
278
+ class _Chat:
279
+ def __init__(self, client: Dyadic) -> None:
280
+ self.completions = _ChatCompletions(client)
281
+
282
+
283
+ class _Completions:
284
+ def __init__(self, client: Dyadic) -> None:
285
+ self._client = client
286
+
287
+ def create(self, *, model: str, prompt: str, stream: bool = False, **params: Any) -> Union[APIObject, Stream]:
288
+ body = _stream_body({"model": model, "prompt": prompt, **params}, stream)
289
+ return self._client.request("POST", "/completions", body, stream=stream)
290
+
291
+
292
+ class _AsyncModels:
293
+ def __init__(self, client: AsyncDyadic) -> None:
294
+ self._client = client
295
+
296
+ async def list(self) -> List[APIObject]:
297
+ return (await self._client.request("GET", "/models")).data
298
+
299
+ async def retrieve(self, model: str) -> APIObject:
300
+ return await self._client.request("GET", f"/models/{model}")
301
+
302
+
303
+ class _AsyncChatCompletions:
304
+ def __init__(self, client: AsyncDyadic) -> None:
305
+ self._client = client
306
+
307
+ async def create(self, *, model: str, messages: Messages, stream: bool = False, **params: Any) -> Union[APIObject, AsyncStream]:
308
+ body = _stream_body({"model": model, "messages": messages, **params}, stream)
309
+ return await self._client.request("POST", "/chat/completions", body, stream=stream)
310
+
311
+
312
+ class _AsyncChat:
313
+ def __init__(self, client: AsyncDyadic) -> None:
314
+ self.completions = _AsyncChatCompletions(client)
315
+
316
+
317
+ class _AsyncCompletions:
318
+ def __init__(self, client: AsyncDyadic) -> None:
319
+ self._client = client
320
+
321
+ async def create(self, *, model: str, prompt: str, stream: bool = False, **params: Any) -> Union[APIObject, AsyncStream]:
322
+ body = _stream_body({"model": model, "prompt": prompt, **params}, stream)
323
+ return await self._client.request("POST", "/completions", body, stream=stream)
324
+
325
+
326
+ class _Resource:
327
+ def __init__(self, client: Any) -> None:
328
+ self._client = client
329
+
330
+
331
+ class _Responses(_Resource):
332
+ def create(self, *, model: str, input: Any, stream: bool = False, **params: Any) -> Any:
333
+ """POST /responses. ``input`` is a string or a list of items; continue
334
+ a conversation with ``previous_response_id``. With ``stream=True``
335
+ returns a stream of Responses events (``stream.response`` at the end)."""
336
+ body = _clean({"model": model, "input": input, **params})
337
+ if stream:
338
+ body["stream"] = True
339
+ return self._client.request("POST", "/responses", body, stream=stream)
340
+
341
+ def retrieve(self, response_id: str) -> Any:
342
+ return self._client.request("GET", f"/responses/{response_id}")
343
+
344
+ def delete(self, response_id: str) -> Any:
345
+ return self._client.request("DELETE", f"/responses/{response_id}")
346
+
347
+ def input_items(self, response_id: str, **query: Any) -> Any:
348
+ return self._client.request("GET", f"/responses/{response_id}/input_items", query=query)
349
+
350
+ def cancel(self, response_id: str) -> Any:
351
+ return self._client.request("POST", f"/responses/{response_id}/cancel")
352
+
353
+
354
+ class _Embeddings(_Resource):
355
+ def create(self, *, model: str, input: Union[str, List[str]], **params: Any) -> Any:
356
+ """POST /embeddings (text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002)."""
357
+ return self._client.request("POST", "/embeddings", _clean({"model": model, "input": input, **params}))
358
+
359
+
360
+ class _Speech(_Resource):
361
+ def create(self, *, model: str, voice: str, input: str, **params: Any) -> Any:
362
+ """POST /audio/speech: returns Binary audio (``.content``, ``.save(path)``)."""
363
+ return self._client.request("POST", "/audio/speech", _clean({"model": model, "voice": voice, "input": input, **params}), raw=True)
364
+
365
+
366
+ class _Transcriptions(_Resource):
367
+ path = "/audio/transcriptions"
368
+
369
+ def create(self, *, file: FileInput, model: str, **params: Any) -> Any:
370
+ """Audio to text. Returns an APIObject for json formats, a str for
371
+ text, srt and vtt."""
372
+ return self._client.request("POST", self.path, {"model": model, **params}, files={"file": _file(file, "audio")})
373
+
374
+
375
+ class _Translations(_Transcriptions):
376
+ path = "/audio/translations"
377
+
378
+
379
+ class _Audio:
380
+ def __init__(self, client: Any) -> None:
381
+ self.speech = _Speech(client)
382
+ self.transcriptions = _Transcriptions(client)
383
+ self.translations = _Translations(client)
384
+
385
+
386
+ class _Moderations(_Resource):
387
+ def create(self, *, input: Any, model: Optional[str] = None) -> Any:
388
+ return self._client.request("POST", "/moderations", _clean({"input": input, "model": model}))
389
+
390
+
391
+ class _Images(_Resource):
392
+ def generate(self, prompt: str, **params: Any) -> Any:
393
+ return self._client.request("POST", "/images/generations", _clean({"prompt": prompt, **params}), cast=Image)
394
+
395
+ def edit(self, *, image: FileInput, prompt: str, **params: Any) -> Any:
396
+ """Change an image as the prompt describes."""
397
+ return self._client.request("POST", "/images/edits", {"prompt": prompt, **params}, files={"image": _file(image, "image.png")}, cast=Image)
398
+
399
+ def create_variation(self, *, image: FileInput, **params: Any) -> Any:
400
+ return self._client.request("POST", "/images/variations", params, files={"image": _file(image, "image.png")}, cast=Image)
401
+
402
+
403
+ class _Files(_Resource):
404
+ def create(self, *, file: FileInput, purpose: str) -> Any:
405
+ """Upload a file (purpose batch for batch input, user_data to reference by file_id)."""
406
+ return self._client.request("POST", "/files", {"purpose": purpose}, files={"file": _file(file)})
407
+
408
+ def list(self, **query: Any) -> Any:
409
+ return self._client.request("GET", "/files", query=query)
410
+
411
+ def retrieve(self, file_id: str) -> Any:
412
+ return self._client.request("GET", f"/files/{file_id}")
413
+
414
+ def delete(self, file_id: str) -> Any:
415
+ return self._client.request("DELETE", f"/files/{file_id}")
416
+
417
+ def content(self, file_id: str) -> Any:
418
+ """The file's bytes as Binary (``.content``, ``.text``, ``.save(path)``)."""
419
+ return self._client.request("GET", f"/files/{file_id}/content", raw=True)
420
+
421
+
422
+ class _Batches(_Resource):
423
+ def create(self, *, input_file_id: str, endpoint: str, completion_window: str = "24h", metadata: Optional[dict] = None) -> Any:
424
+ return self._client.request("POST", "/batches", _clean({"input_file_id": input_file_id, "endpoint": endpoint,
425
+ "completion_window": completion_window, "metadata": metadata}))
426
+
427
+ def list(self, **query: Any) -> Any:
428
+ return self._client.request("GET", "/batches", query=query)
429
+
430
+ def retrieve(self, batch_id: str) -> Any:
431
+ return self._client.request("GET", f"/batches/{batch_id}")
432
+
433
+ def cancel(self, batch_id: str) -> Any:
434
+ return self._client.request("POST", f"/batches/{batch_id}/cancel")
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class DyadicError(Exception):
7
+ """An error from the API (or the connection to it).
8
+
9
+ ``status`` is the HTTP status (0 for a connection failure); ``type``,
10
+ ``code`` and ``param`` come from the API's OpenAI-shaped error body.
11
+ """
12
+
13
+ def __init__(self, message: str, *, status: int = 0, type: str = "", code: Optional[str] = None,
14
+ param: Optional[str] = None, request_id: str = "", body: Any = None) -> None:
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.status = status
18
+ self.type = type
19
+ self.code = code
20
+ self.param = param
21
+ self.request_id = request_id
22
+ self.body = body
23
+
24
+ def __repr__(self) -> str:
25
+ return f"DyadicError(status={self.status}, code={self.code!r}, message={self.message!r})"
26
+
27
+ @classmethod
28
+ def from_response(cls, status: int, data: Any, request_id: str = "") -> "DyadicError":
29
+ error = data.get("error") if isinstance(data, dict) else None
30
+ if isinstance(error, dict):
31
+ return cls(str(error.get("message") or f"HTTP {status}"), status=status, type=str(error.get("type") or ""),
32
+ code=error.get("code"), param=error.get("param"), request_id=request_id, body=data)
33
+ return cls(f"HTTP {status}", status=status, request_id=request_id, body=data)
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from typing import Any, AsyncIterator, Iterator, Optional
6
+
7
+ from ._errors import DyadicError
8
+
9
+
10
+ class APIObject(dict):
11
+ """A response object: a dict whose keys are also attributes."""
12
+
13
+ def __getattr__(self, name: str) -> Any:
14
+ try:
15
+ return self[name]
16
+ except KeyError:
17
+ if name.startswith("__"):
18
+ raise AttributeError(name) from None
19
+ return None
20
+
21
+ @classmethod
22
+ def wrap(cls, value: Any) -> Any:
23
+ if isinstance(value, dict):
24
+ return cls({k: cls.wrap(v) for k, v in value.items()})
25
+ if isinstance(value, list):
26
+ return [cls.wrap(v) for v in value]
27
+ return value
28
+
29
+ @property
30
+ def text(self) -> str:
31
+ """A chunk's or completion's text (content delta, message or legacy text)."""
32
+ if self.get("type") == "response.output_text.delta":
33
+ return self.get("delta") or ""
34
+ if self.get("object") == "response":
35
+ return self.get("output_text") or "".join(
36
+ part.get("text") or "" for item in self.get("output") or [] if item.get("type") == "message" for part in item.get("content") or [])
37
+ choices = self.get("choices") or []
38
+ if not choices:
39
+ return ""
40
+ choice = choices[0]
41
+ delta = choice.get("delta") or {}
42
+ message = choice.get("message") or {}
43
+ return delta.get("content") or message.get("content") or choice.get("text") or ""
44
+
45
+
46
+ def _events(lines: Iterator[str]) -> Iterator[APIObject]:
47
+ for line in lines:
48
+ if not line.startswith("data:"):
49
+ continue
50
+ data = line[5:].strip()
51
+ if data == "[DONE]":
52
+ return
53
+ if not data:
54
+ continue
55
+ chunk = json.loads(data)
56
+ if isinstance(chunk, dict) and chunk.get("error"):
57
+ raise DyadicError.from_response(200, chunk)
58
+ if isinstance(chunk, dict) and chunk.get("type") == "response.failed":
59
+ raise DyadicError.from_response(200, {"error": (chunk.get("response") or {}).get("error") or {"message": "The response failed."}})
60
+ yield APIObject.wrap(chunk)
61
+
62
+
63
+ class Stream:
64
+ """Iterate a streamed completion chunk by chunk. ``usage`` holds the final
65
+ usage (when the server sends it) and ``text`` the text so far."""
66
+
67
+ def __init__(self, response: Any) -> None:
68
+ self._response = response
69
+ self.usage: Optional[APIObject] = None
70
+ self.response: Optional[APIObject] = None
71
+ self.text = ""
72
+ self.request_id = response.headers.get("x-request-id", "")
73
+
74
+ def _take(self, chunk: APIObject) -> None:
75
+ if chunk.get("usage"):
76
+ self.usage = chunk["usage"]
77
+ if chunk.get("type") in ("response.completed", "response.incomplete") and chunk.get("response"):
78
+ self.response = chunk["response"]
79
+ self.usage = chunk["response"].get("usage")
80
+ self.text += chunk.text
81
+
82
+ def __iter__(self) -> Iterator[APIObject]:
83
+ try:
84
+ for chunk in _events(self._response.iter_lines()):
85
+ self._take(chunk)
86
+ yield chunk
87
+ finally:
88
+ self._response.close()
89
+
90
+ def until_done(self) -> str:
91
+ for _ in self:
92
+ pass
93
+ return self.text
94
+
95
+ def close(self) -> None:
96
+ self._response.close()
97
+
98
+ def __enter__(self) -> "Stream":
99
+ return self
100
+
101
+ def __exit__(self, *exc: Any) -> None:
102
+ self.close()
103
+
104
+
105
+ class AsyncStream:
106
+ """The async version of :class:`Stream` (``async for chunk in stream``)."""
107
+
108
+ def __init__(self, response: Any) -> None:
109
+ self._response = response
110
+ self.usage: Optional[APIObject] = None
111
+ self.response: Optional[APIObject] = None
112
+ self.text = ""
113
+ self.request_id = response.headers.get("x-request-id", "")
114
+
115
+ def _take(self, chunk: APIObject) -> None:
116
+ if chunk.get("usage"):
117
+ self.usage = chunk["usage"]
118
+ if chunk.get("type") in ("response.completed", "response.incomplete") and chunk.get("response"):
119
+ self.response = chunk["response"]
120
+ self.usage = chunk["response"].get("usage")
121
+ self.text += chunk.text
122
+
123
+ async def __aiter__(self) -> AsyncIterator[APIObject]:
124
+ try:
125
+ async for line in self._response.aiter_lines():
126
+ for chunk in _events(iter([line])):
127
+ self._take(chunk)
128
+ yield chunk
129
+ if line.strip() == "data: [DONE]":
130
+ return
131
+ finally:
132
+ await self._response.aclose()
133
+
134
+ async def until_done(self) -> str:
135
+ async for _ in self:
136
+ pass
137
+ return self.text
138
+
139
+ async def close(self) -> None:
140
+ await self._response.aclose()
141
+
142
+ async def __aenter__(self) -> "AsyncStream":
143
+ return self
144
+
145
+ async def __aexit__(self, *exc: Any) -> None:
146
+ await self.close()
147
+
148
+
149
+ class Image(APIObject):
150
+ """A generated image: ``bytes`` (when returned inline), ``url``, ``save()``."""
151
+
152
+ @property
153
+ def bytes(self) -> Optional[bytes]:
154
+ item = (self.get("data") or [{}])[0]
155
+ return base64.b64decode(item["b64_json"]) if item.get("b64_json") else None
156
+
157
+ @property
158
+ def url(self) -> Optional[str]:
159
+ return (self.get("data") or [{}])[0].get("url")
160
+
161
+ def save(self, path: str) -> str:
162
+ content = self.bytes
163
+ if content is None:
164
+ raise DyadicError("This image was returned as a URL; download it from image.url.")
165
+ with open(path, "wb") as handle:
166
+ handle.write(content)
167
+ return path
168
+
169
+
170
+ class Binary:
171
+ """Bytes from the API (speech audio, file downloads): ``content``,
172
+ ``content_type``, ``text`` and ``save(path)``."""
173
+
174
+ def __init__(self, content: bytes, content_type: str = "", request_id: str = "") -> None:
175
+ self.content = content
176
+ self.content_type = content_type
177
+ self.request_id = request_id
178
+
179
+ @property
180
+ def text(self) -> str:
181
+ return self.content.decode("utf-8", errors="replace")
182
+
183
+ def save(self, path: str) -> str:
184
+ with open(path, "wb") as handle:
185
+ handle.write(self.content)
186
+ return path
187
+
188
+ def __len__(self) -> int:
189
+ return len(self.content)
190
+
191
+ def __repr__(self) -> str:
192
+ return f"Binary({self.content_type or 'application/octet-stream'}, {len(self.content)} bytes)"
File without changes
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: dyadic
3
+ Version: 1.0.0
4
+ Summary: The official Python SDK for the Dyadic API (Iris, OpenAI-compatible).
5
+ Author: Dyadic
6
+ License: MIT
7
+ Project-URL: Homepage, https://dyadic.app/developers/
8
+ Project-URL: Documentation, https://dyadic.app/developers/
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: httpx<1,>=0.25
15
+
16
+ # dyadic
17
+
18
+ The official Python SDK for the [Dyadic API](https://dyadic.app/developers/): use Iris
19
+ from Python with streaming, function calling, images and typed errors. Requests use your Dyadic credits.
20
+
21
+ ```sh
22
+ pip install dyadic
23
+ ```
24
+
25
+ ```python
26
+ from dyadic import Dyadic
27
+
28
+ client = Dyadic() # reads DYADIC_API_KEY
29
+ print(client.ask("Hello!", model="iris"))
30
+ ```
31
+
32
+ See https://dyadic.app/developers/ for the full guide.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/dyadic/__init__.py
4
+ src/dyadic/_client.py
5
+ src/dyadic/_errors.py
6
+ src/dyadic/_types.py
7
+ src/dyadic/py.typed
8
+ src/dyadic.egg-info/PKG-INFO
9
+ src/dyadic.egg-info/SOURCES.txt
10
+ src/dyadic.egg-info/dependency_links.txt
11
+ src/dyadic.egg-info/requires.txt
12
+ src/dyadic.egg-info/top_level.txt
13
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ httpx<1,>=0.25
@@ -0,0 +1 @@
1
+ dyadic
@@ -0,0 +1,159 @@
1
+ """The dyadic SDK against a mocked API: requests carry the key, app name and
2
+ OpenAI fields; responses read as attributes; streams yield chunks, text and
3
+ usage; tool-call chunks arrive; errors raise DyadicError with the API's
4
+ code; retryable failures retry but a quota error does not; async mirrors sync;
5
+ images decode and save."""
6
+ import base64
7
+ import json
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from dyadic import AsyncDyadic, Dyadic, DyadicError
13
+
14
+ SSE = "".join(f"data: {json.dumps(c)}\n\n" for c in (
15
+ {"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}}]},
16
+ {"choices": [{"index": 0, "delta": {"content": "Hel"}}]},
17
+ {"choices": [{"index": 0, "delta": {"content": "lo"}}]},
18
+ {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "f", "arguments": "{}"}}]}}]},
19
+ {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2, "dyadic": {"credits": 1.5}}},
20
+ )) + "data: [DONE]\n\n"
21
+
22
+
23
+ def handler(seen):
24
+ def respond(request: httpx.Request) -> httpx.Response:
25
+ body = json.loads(request.content or b"{}")
26
+ seen.append((request, body))
27
+ if request.url.path.endswith("/models"):
28
+ return httpx.Response(200, json={"object": "list", "data": [{"id": "iris"}, {"id": "iris-think"}]})
29
+ if request.url.path.endswith("/images/generations"):
30
+ return httpx.Response(200, json={"created": 1, "data": [{"b64_json": base64.b64encode(b"png").decode()}]})
31
+ if body.get("model") == "broke":
32
+ return httpx.Response(429, json={"error": {"message": "Out of credits", "type": "insufficient_quota", "code": "insufficient_quota"}})
33
+ if body.get("model") == "flaky" and len(seen) < 2:
34
+ return httpx.Response(503, json={"error": {"message": "busy", "type": "server_error"}}, headers={"retry-after": "0"})
35
+ if body.get("stream"):
36
+ return httpx.Response(200, text=SSE, headers={"content-type": "text/event-stream", "x-request-id": "r1"})
37
+ return httpx.Response(200, json={"id": "x", "object": "chat.completion", "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hi there"}, "finish_reason": "stop"}],
38
+ "usage": {"total_tokens": 5}, "dyadic": {"credits": 0.5}})
39
+ return respond
40
+
41
+
42
+ def client(seen, **kwargs):
43
+ return Dyadic(api_key="dya-test", app_name="Tests", http_client=httpx.Client(transport=httpx.MockTransport(handler(seen))), **kwargs)
44
+
45
+
46
+ def test_requests_responses_and_streams():
47
+ seen = []
48
+ api = client(seen)
49
+ assert api.ask("Hello", model="iris", system="Be brief.", top_p=0.5) == "Hi there"
50
+ request, body = seen[-1]
51
+ assert request.headers["authorization"] == "Bearer dya-test" and request.headers["x-title"] == "Tests"
52
+ assert str(request.url) == "https://dyadic.app/api/openai/v1/chat/completions"
53
+ assert body == {"model": "iris", "messages": [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "Hello"}], "top_p": 0.5}
54
+ reply = api.chat.completions.create(model="iris", messages=[{"role": "user", "content": "x"}])
55
+ assert reply.choices[0].message.content == "Hi there" and reply.dyadic.credits == 0.5 and reply.missing is None
56
+ stream = api.chat.completions.create(model="iris", messages=[{"role": "user", "content": "x"}], stream=True)
57
+ chunks = list(stream)
58
+ assert seen[-1][1]["stream_options"] == {"include_usage": True} and stream.text == "Hello" and stream.usage.dyadic.credits == 1.5
59
+ assert chunks[3].choices[0].delta.tool_calls[0].function.name == "f" and stream.request_id == "r1"
60
+ assert [m.id for m in api.models.list()] == ["iris", "iris-think"]
61
+ assert api.images.generate("a cat").bytes == b"png"
62
+
63
+
64
+ def test_errors_and_retries(tmp_path, monkeypatch):
65
+ monkeypatch.setattr("dyadic._client._delay", lambda *a: 0)
66
+ seen = []
67
+ api = client(seen)
68
+ with pytest.raises(DyadicError) as error:
69
+ api.ask("x", model="broke")
70
+ assert error.value.status == 429 and error.value.code == "insufficient_quota" and len(seen) == 1
71
+ seen.clear()
72
+ assert api.ask("x", model="flaky") == "Hi there" and len(seen) == 2
73
+ image = api.images.generate("a dog")
74
+ assert open(image.save(str(tmp_path / "dog.png")), "rb").read() == b"png"
75
+ monkeypatch.delenv("DYADIC_API_KEY", raising=False)
76
+ with pytest.raises(DyadicError):
77
+ Dyadic()
78
+
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_async_client():
82
+ seen = []
83
+ async with AsyncDyadic(api_key="dya-test", http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler(seen)))) as api:
84
+ assert await api.ask("Hello") == "Hi there"
85
+ stream = await api.chat.completions.create(model="iris", messages=[{"role": "user", "content": "x"}], stream=True)
86
+ assert [c.text async for c in stream][1:3] == ["Hel", "lo"] and stream.usage.prompt_tokens == 3
87
+ assert [m.id for m in await api.models.list()] == ["iris", "iris-think"]
88
+
89
+
90
+ RESPONSE_SSE = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in (
91
+ {"type": "response.created", "sequence_number": 0, "response": {"id": "resp_1", "status": "in_progress"}},
92
+ {"type": "response.output_text.delta", "sequence_number": 1, "delta": "Hel"},
93
+ {"type": "response.output_text.delta", "sequence_number": 2, "delta": "lo"},
94
+ {"type": "response.completed", "sequence_number": 3, "response": {"id": "resp_1", "status": "completed", "usage": {"total_tokens": 4}}},
95
+ ))
96
+
97
+
98
+ def services(seen):
99
+ def respond(request: httpx.Request) -> httpx.Response:
100
+ path = request.url.path.removeprefix("/api/openai/v1")
101
+ kind = request.headers.get("content-type", "")
102
+ body = json.loads(request.content) if kind.startswith("application/json") else request.content
103
+ seen.append((request, body))
104
+ if path == "/responses" and body.get("stream"):
105
+ return httpx.Response(200, text=RESPONSE_SSE, headers={"content-type": "text/event-stream"})
106
+ if path == "/responses":
107
+ return httpx.Response(200, json={"id": "resp_1", "object": "response", "status": "completed", "output": [
108
+ {"type": "message", "content": [{"type": "output_text", "text": "Hi"}]}]})
109
+ if path == "/audio/speech":
110
+ return httpx.Response(200, content=b"ID3", headers={"content-type": "audio/mpeg"})
111
+ if path == "/audio/transcriptions":
112
+ return httpx.Response(200, text="Hello.", headers={"content-type": "text/plain"})
113
+ if path == "/files/file-1/content":
114
+ return httpx.Response(200, content=b'{"a": 1}\n', headers={"content-type": "application/jsonl"})
115
+ if path == "/images/edits":
116
+ return httpx.Response(200, json={"created": 1, "data": [{"b64_json": base64.b64encode(b"png").decode()}]})
117
+ return httpx.Response(200, json={"object": "echo", "path": path, "query": dict(request.url.params)})
118
+ return respond
119
+
120
+
121
+ def test_service_resources(tmp_path):
122
+ seen = []
123
+ api = Dyadic(api_key="dya-test", http_client=httpx.Client(transport=httpx.MockTransport(services(seen))))
124
+ reply = api.responses.create(model="iris", input="Hi", previous_response_id="resp_0")
125
+ assert reply.text == "Hi" and seen[-1][1] == {"model": "iris", "input": "Hi", "previous_response_id": "resp_0"}
126
+ stream = api.responses.create(model="iris", input="Hi", stream=True)
127
+ assert [e.type for e in stream][-1] == "response.completed" and stream.text == "Hello" and stream.response.id == "resp_1"
128
+ assert stream.usage.total_tokens == 4
129
+ assert api.responses.delete("resp_1").path == "/responses/resp_1" and seen[-1][0].method == "DELETE"
130
+ assert api.responses.input_items("resp_1", limit=5).query == {"limit": "5"}
131
+ assert api.embeddings.create(model="text-embedding-3-small", input=["a"]).path == "/embeddings"
132
+ audio = api.audio.speech.create(model="tts-1", voice="alloy", input="Hello")
133
+ assert audio.content == b"ID3" and audio.content_type == "audio/mpeg" and open(audio.save(str(tmp_path / "a.mp3")), "rb").read() == b"ID3"
134
+ text = api.audio.transcriptions.create(file=("clip.wav", b"RIFF", "audio/wav"), model="whisper-1", response_format="text",
135
+ timestamp_granularities=["segment"])
136
+ request, raw = seen[-1]
137
+ assert text == "Hello." and request.headers["content-type"].startswith("multipart/form-data")
138
+ assert b'name="file"; filename="clip.wav"' in raw and b'name="timestamp_granularities[]"' in raw and b"whisper-1" in raw
139
+ path = tmp_path / "batch.jsonl"
140
+ path.write_text('{"custom_id": "1"}\n')
141
+ api.files.create(file=str(path), purpose="batch")
142
+ assert b'filename="batch.jsonl"' in seen[-1][1] and b"batch" in seen[-1][1]
143
+ assert api.files.content("file-1").text == '{"a": 1}\n'
144
+ assert api.batches.create(input_file_id="file-1", endpoint="/v1/chat/completions").path == "/batches"
145
+ assert seen[-1][1] == {"input_file_id": "file-1", "endpoint": "/v1/chat/completions", "completion_window": "24h"}
146
+ assert api.batches.cancel("batch_1").path == "/batches/batch_1/cancel"
147
+ assert api.moderations.create(input="x").path == "/moderations"
148
+ assert api.images.edit(image=b"png", prompt="Add a hat").bytes == b"png"
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_async_service_resources():
153
+ seen = []
154
+ async with AsyncDyadic(api_key="dya-test", http_client=httpx.AsyncClient(transport=httpx.MockTransport(services(seen)))) as api:
155
+ assert (await api.responses.create(model="iris", input="Hi")).text == "Hi"
156
+ stream = await api.responses.create(model="iris", input="Hi", stream=True)
157
+ assert "".join([e.text async for e in stream]) == "Hello" and stream.response.status == "completed"
158
+ assert (await api.audio.speech.create(model="tts-1", voice="alloy", input="x")).content == b"ID3"
159
+ assert (await api.images.edit(image=b"png", prompt="x")).bytes == b"png"