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 +32 -0
- dyadic-1.0.0/README.md +17 -0
- dyadic-1.0.0/pyproject.toml +24 -0
- dyadic-1.0.0/setup.cfg +4 -0
- dyadic-1.0.0/src/dyadic/__init__.py +18 -0
- dyadic-1.0.0/src/dyadic/_client.py +434 -0
- dyadic-1.0.0/src/dyadic/_errors.py +33 -0
- dyadic-1.0.0/src/dyadic/_types.py +192 -0
- dyadic-1.0.0/src/dyadic/py.typed +0 -0
- dyadic-1.0.0/src/dyadic.egg-info/PKG-INFO +32 -0
- dyadic-1.0.0/src/dyadic.egg-info/SOURCES.txt +13 -0
- dyadic-1.0.0/src/dyadic.egg-info/dependency_links.txt +1 -0
- dyadic-1.0.0/src/dyadic.egg-info/requires.txt +1 -0
- dyadic-1.0.0/src/dyadic.egg-info/top_level.txt +1 -0
- dyadic-1.0.0/tests/test_client.py +159 -0
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,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
|
+
|
|
@@ -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"
|