violet-sdk 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.
- violet/__init__.py +75 -0
- violet/_async.py +503 -0
- violet/_streaming.py +147 -0
- violet/_transport.py +187 -0
- violet/auth.py +432 -0
- violet/client.py +180 -0
- violet/errors.py +150 -0
- violet/fixtures.py +169 -0
- violet/resources.py +211 -0
- violet/types.py +185 -0
- violet_sdk-0.1.0.dist-info/METADATA +316 -0
- violet_sdk-0.1.0.dist-info/RECORD +14 -0
- violet_sdk-0.1.0.dist-info/WHEEL +4 -0
- violet_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
violet/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Violet — Python client for the Violet Messages API.
|
|
2
|
+
|
|
3
|
+
from violet import Violet
|
|
4
|
+
client = Violet(workspace_id="ws_…") # Auth0 default; or api_key="vio_sk_…"
|
|
5
|
+
msg = client.messages.create(
|
|
6
|
+
model="Violet Test Model", max_tokens=64, # gateway sets the model
|
|
7
|
+
messages=[{"role": "user", "content": "Say hi"}],
|
|
8
|
+
)
|
|
9
|
+
print(msg.content[0].text)
|
|
10
|
+
|
|
11
|
+
See README for the full surface map. `model=` is required by the API shape,
|
|
12
|
+
but the gateway sets the served model.
|
|
13
|
+
"""
|
|
14
|
+
from .auth import Authenticator
|
|
15
|
+
from .client import Violet
|
|
16
|
+
from ._async import AsyncViolet
|
|
17
|
+
from .errors import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APIError,
|
|
20
|
+
APIStatusError,
|
|
21
|
+
APITimeoutError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
AuthError,
|
|
24
|
+
BadRequestError,
|
|
25
|
+
InternalServerError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
PermissionDeniedError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
UnprocessableEntityError,
|
|
30
|
+
VioletError,
|
|
31
|
+
)
|
|
32
|
+
from .types import (
|
|
33
|
+
ContentBlock,
|
|
34
|
+
Event,
|
|
35
|
+
Message,
|
|
36
|
+
TextBlock,
|
|
37
|
+
ThinkingBlock,
|
|
38
|
+
TokenCount,
|
|
39
|
+
ToolUseBlock,
|
|
40
|
+
Usage,
|
|
41
|
+
UsageReport,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# clients
|
|
46
|
+
"Violet",
|
|
47
|
+
"AsyncViolet",
|
|
48
|
+
"Authenticator",
|
|
49
|
+
# objects
|
|
50
|
+
"Message",
|
|
51
|
+
"ContentBlock",
|
|
52
|
+
"TextBlock",
|
|
53
|
+
"ThinkingBlock",
|
|
54
|
+
"ToolUseBlock",
|
|
55
|
+
"Usage",
|
|
56
|
+
"TokenCount",
|
|
57
|
+
"Event",
|
|
58
|
+
"UsageReport",
|
|
59
|
+
# errors
|
|
60
|
+
"VioletError",
|
|
61
|
+
"AuthError",
|
|
62
|
+
"APIError",
|
|
63
|
+
"APIStatusError",
|
|
64
|
+
"APIConnectionError",
|
|
65
|
+
"APITimeoutError",
|
|
66
|
+
"BadRequestError",
|
|
67
|
+
"AuthenticationError",
|
|
68
|
+
"PermissionDeniedError",
|
|
69
|
+
"NotFoundError",
|
|
70
|
+
"UnprocessableEntityError",
|
|
71
|
+
"RateLimitError",
|
|
72
|
+
"InternalServerError",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
__version__ = "0.1.0"
|
violet/_async.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Native-async client — `AsyncViolet`, the async twin of sync `Violet`.
|
|
2
|
+
|
|
3
|
+
Unlike the sync `Violet` (stdlib-only `urllib`), the async path does real
|
|
4
|
+
non-blocking I/O via **aiohttp**, an OPTIONAL dependency: ``pip install
|
|
5
|
+
violet-sdk[async]``. aiohttp is imported lazily, so importing `violet` never
|
|
6
|
+
requires it — only constructing/using `AsyncViolet` does.
|
|
7
|
+
|
|
8
|
+
The rare Auth0 token resolution reuses the (sync) `Authenticator` via a thread
|
|
9
|
+
(`asyncio.to_thread`) — it's not the hot path; the message/stream calls are
|
|
10
|
+
genuinely async. Body-building, error mapping, SSE accumulation, and the typed
|
|
11
|
+
objects are shared with the sync stack.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import random
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from typing import Any, AsyncIterator, List, Optional
|
|
20
|
+
|
|
21
|
+
from .auth import Authenticator
|
|
22
|
+
from .errors import APIConnectionError, APIError, APITimeoutError, VioletError, error_from_status
|
|
23
|
+
from ._streaming import accumulate_frame, text_delta_of
|
|
24
|
+
from .resources import _message_body, _resolve_schema, _unwrap
|
|
25
|
+
from .types import Event, Message, TokenCount, UsageReport
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.violetai.ca"
|
|
28
|
+
DEFAULT_USER_AGENT = (
|
|
29
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
30
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
31
|
+
)
|
|
32
|
+
_RETRY_STATUSES = {408, 409, 429}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _require_aiohttp():
|
|
36
|
+
try:
|
|
37
|
+
import aiohttp # noqa: F401
|
|
38
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
39
|
+
raise VioletError(
|
|
40
|
+
"AsyncViolet needs aiohttp. Install it with: pip install violet-sdk[async]"
|
|
41
|
+
) from exc
|
|
42
|
+
return aiohttp
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _backoff(attempt: int, headers) -> float:
|
|
46
|
+
if headers is not None:
|
|
47
|
+
ra = headers.get("retry-after")
|
|
48
|
+
if ra:
|
|
49
|
+
try:
|
|
50
|
+
return min(float(ra), 60.0)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
return min(0.5 * (2 ** attempt) + random.uniform(0, 0.25), 8.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Async SSE parser ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async def async_sse_frames(byte_aiter: AsyncIterator[bytes]) -> AsyncIterator[dict]:
|
|
59
|
+
"""Yield one parsed JSON object per SSE frame from an async byte source."""
|
|
60
|
+
buf = ""
|
|
61
|
+
async for chunk in byte_aiter:
|
|
62
|
+
buf += chunk.decode("utf-8", "replace") if isinstance(chunk, (bytes, bytearray)) else str(chunk)
|
|
63
|
+
while True:
|
|
64
|
+
i2, i4 = buf.find("\n\n"), buf.find("\r\n\r\n")
|
|
65
|
+
if i4 != -1 and (i2 == -1 or i4 < i2):
|
|
66
|
+
frame, buf = buf[:i4], buf[i4 + 4:]
|
|
67
|
+
elif i2 != -1:
|
|
68
|
+
frame, buf = buf[:i2], buf[i2 + 2:]
|
|
69
|
+
else:
|
|
70
|
+
break
|
|
71
|
+
done, objs = _parse_frame(frame)
|
|
72
|
+
for obj in objs:
|
|
73
|
+
yield obj
|
|
74
|
+
if done:
|
|
75
|
+
return
|
|
76
|
+
_done, objs = _parse_frame(buf)
|
|
77
|
+
for obj in objs:
|
|
78
|
+
yield obj
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_frame(frame: str):
|
|
82
|
+
data_lines = []
|
|
83
|
+
for line in frame.replace("\r", "").split("\n"):
|
|
84
|
+
if line.startswith(":"):
|
|
85
|
+
continue
|
|
86
|
+
if line.startswith("data:"):
|
|
87
|
+
data_lines.append(line[5:].lstrip())
|
|
88
|
+
if not data_lines:
|
|
89
|
+
return (False, [])
|
|
90
|
+
payload = "\n".join(data_lines)
|
|
91
|
+
if payload.strip() == "[DONE]":
|
|
92
|
+
return (True, [])
|
|
93
|
+
try:
|
|
94
|
+
return (False, [json.loads(payload)])
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
return (False, [{"_raw": payload}])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Async stream helper ───────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
class AsyncMessageStream:
|
|
102
|
+
"""Async mirror of `MessageStream`: `async for` over events, `text_stream`
|
|
103
|
+
(async), and `await get_final_message()`. A single internal consumer is
|
|
104
|
+
shared by all three. `opener` is an async callable returning
|
|
105
|
+
``(byte_aiterator, aclose_coro_or_None)``."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, opener):
|
|
108
|
+
self._opener = opener
|
|
109
|
+
self._acc: dict = {"content": []}
|
|
110
|
+
self._gen = None
|
|
111
|
+
|
|
112
|
+
def _ensure_gen(self):
|
|
113
|
+
if self._gen is None:
|
|
114
|
+
self._gen = self._events()
|
|
115
|
+
return self._gen
|
|
116
|
+
|
|
117
|
+
def __aiter__(self):
|
|
118
|
+
return self._ensure_gen()
|
|
119
|
+
|
|
120
|
+
async def _events(self):
|
|
121
|
+
byte_aiter, aclose = await self._opener()
|
|
122
|
+
try:
|
|
123
|
+
async for frame in async_sse_frames(byte_aiter):
|
|
124
|
+
accumulate_frame(self._acc, frame)
|
|
125
|
+
yield Event(frame)
|
|
126
|
+
finally:
|
|
127
|
+
if aclose is not None:
|
|
128
|
+
try:
|
|
129
|
+
await aclose()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def text_stream(self):
|
|
135
|
+
return self._text_stream()
|
|
136
|
+
|
|
137
|
+
async def _text_stream(self):
|
|
138
|
+
async for ev in self._ensure_gen():
|
|
139
|
+
td = text_delta_of(ev._data)
|
|
140
|
+
if td:
|
|
141
|
+
yield td
|
|
142
|
+
|
|
143
|
+
async def get_final_message(self) -> Message:
|
|
144
|
+
async for _ in self._ensure_gen():
|
|
145
|
+
pass
|
|
146
|
+
return Message(self._acc)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── The async client ──────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
class AsyncViolet:
|
|
152
|
+
"""Async Violet client (aiohttp-backed).
|
|
153
|
+
|
|
154
|
+
async with AsyncViolet(workspace_id="ws_…") as client:
|
|
155
|
+
msg = await client.messages.create(model=…, max_tokens=…, messages=[…])
|
|
156
|
+
print(msg.content[0].text)
|
|
157
|
+
|
|
158
|
+
async with client.messages.stream(model=…, messages=[…]) as stream:
|
|
159
|
+
async for text in stream.text_stream:
|
|
160
|
+
...
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
auth: Optional[Authenticator] = None,
|
|
167
|
+
auth_token: Optional[str] = None,
|
|
168
|
+
api_key: Optional[str] = None,
|
|
169
|
+
workspace_id: Optional[str] = None,
|
|
170
|
+
base_url: Optional[str] = None,
|
|
171
|
+
surface: str = "sdk",
|
|
172
|
+
timeout: float = 60.0,
|
|
173
|
+
max_retries: int = 2,
|
|
174
|
+
user_agent: Optional[str] = None,
|
|
175
|
+
raw_passthrough: bool = False,
|
|
176
|
+
):
|
|
177
|
+
import os
|
|
178
|
+
|
|
179
|
+
self._auth = auth or Authenticator()
|
|
180
|
+
resolved_key = api_key or os.environ.get("VIOLET_API_KEY")
|
|
181
|
+
if resolved_key:
|
|
182
|
+
self._auth._api_key = resolved_key
|
|
183
|
+
elif auth_token:
|
|
184
|
+
self._auth._pre = auth_token
|
|
185
|
+
self.base_url = (base_url or os.environ.get("MESSAGES_API_URL", DEFAULT_BASE_URL)).rstrip("/")
|
|
186
|
+
self._workspace_id = (
|
|
187
|
+
workspace_id
|
|
188
|
+
or os.environ.get("WORKSPACE_ID")
|
|
189
|
+
or os.environ.get("VIOLET_WORKSPACE_ID")
|
|
190
|
+
)
|
|
191
|
+
self.surface = surface
|
|
192
|
+
self.timeout = timeout
|
|
193
|
+
self.max_retries = max_retries
|
|
194
|
+
self.user_agent = user_agent or os.environ.get("VIOLET_USER_AGENT", DEFAULT_USER_AGENT)
|
|
195
|
+
self.raw_passthrough = raw_passthrough
|
|
196
|
+
self._session = None # lazily created aiohttp.ClientSession
|
|
197
|
+
|
|
198
|
+
self.messages = _AsyncMessages(self)
|
|
199
|
+
self.auth = _AsyncAuth(self)
|
|
200
|
+
self.usage = _AsyncUsage(self)
|
|
201
|
+
|
|
202
|
+
async def __aenter__(self) -> "AsyncViolet":
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
async def __aexit__(self, *exc) -> None:
|
|
206
|
+
await self.aclose()
|
|
207
|
+
|
|
208
|
+
async def aclose(self) -> None:
|
|
209
|
+
if self._session is not None:
|
|
210
|
+
await self._session.close()
|
|
211
|
+
self._session = None
|
|
212
|
+
|
|
213
|
+
# ── transport ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
async def _ensure_session(self):
|
|
216
|
+
if self._session is None:
|
|
217
|
+
aiohttp = _require_aiohttp()
|
|
218
|
+
self._session = aiohttp.ClientSession()
|
|
219
|
+
return self._session
|
|
220
|
+
|
|
221
|
+
async def _token(self) -> str:
|
|
222
|
+
return await asyncio.to_thread(self._auth.token)
|
|
223
|
+
|
|
224
|
+
def _headers(self, *, workspace_id: Optional[str], json_body: bool, stream: bool, token: str) -> dict:
|
|
225
|
+
h = {
|
|
226
|
+
"Authorization": f"Bearer {token}",
|
|
227
|
+
"X-Surface": self.surface,
|
|
228
|
+
"User-Agent": self.user_agent, # avoid Cloudflare bot block (error 1010)
|
|
229
|
+
"Accept": "text/event-stream" if stream else "application/json",
|
|
230
|
+
}
|
|
231
|
+
if json_body:
|
|
232
|
+
h["Content-Type"] = "application/json"
|
|
233
|
+
if workspace_id:
|
|
234
|
+
h["X-Workspace-Id"] = workspace_id
|
|
235
|
+
return h
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _request_id(headers) -> Optional[str]:
|
|
239
|
+
return headers.get("request-id") or headers.get("x-request-id")
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
async def _read_error_body(resp) -> Any:
|
|
243
|
+
try:
|
|
244
|
+
text = await resp.text()
|
|
245
|
+
except Exception:
|
|
246
|
+
return {}
|
|
247
|
+
if not text:
|
|
248
|
+
return {}
|
|
249
|
+
try:
|
|
250
|
+
return json.loads(text)
|
|
251
|
+
except json.JSONDecodeError:
|
|
252
|
+
return {"error": text.strip()[:500]}
|
|
253
|
+
|
|
254
|
+
async def request(self, method: str, path: str, *, body=None, params=None,
|
|
255
|
+
workspace_id=None, need_workspace=False):
|
|
256
|
+
aiohttp = _require_aiohttp()
|
|
257
|
+
session = await self._ensure_session()
|
|
258
|
+
url = f"{self.base_url}{path}"
|
|
259
|
+
ws = (workspace_id or await self.ensure_workspace()) if need_workspace else None
|
|
260
|
+
payload = json.dumps(body) if body is not None else None
|
|
261
|
+
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
262
|
+
|
|
263
|
+
attempt = 0
|
|
264
|
+
while True:
|
|
265
|
+
token = await self._token()
|
|
266
|
+
headers = self._headers(workspace_id=ws, json_body=body is not None, stream=False, token=token)
|
|
267
|
+
try:
|
|
268
|
+
async with session.request(
|
|
269
|
+
method, url, data=payload, headers=headers, params=params, timeout=timeout
|
|
270
|
+
) as resp:
|
|
271
|
+
rid = self._request_id(resp.headers)
|
|
272
|
+
if resp.status < 400:
|
|
273
|
+
raw = await resp.read()
|
|
274
|
+
data = json.loads(raw) if raw else {}
|
|
275
|
+
return data, rid
|
|
276
|
+
status = resp.status
|
|
277
|
+
err_body = await self._read_error_body(resp)
|
|
278
|
+
if (status in _RETRY_STATUSES or status >= 500) and attempt < self.max_retries:
|
|
279
|
+
await asyncio.sleep(_backoff(attempt, resp.headers))
|
|
280
|
+
attempt += 1
|
|
281
|
+
continue
|
|
282
|
+
raise error_from_status(status, err_body, rid)
|
|
283
|
+
except asyncio.TimeoutError as exc:
|
|
284
|
+
if attempt < self.max_retries:
|
|
285
|
+
await asyncio.sleep(_backoff(attempt, None))
|
|
286
|
+
attempt += 1
|
|
287
|
+
continue
|
|
288
|
+
raise APITimeoutError(f"request to {url} timed out") from exc
|
|
289
|
+
except aiohttp.ClientError as exc:
|
|
290
|
+
if attempt < self.max_retries:
|
|
291
|
+
await asyncio.sleep(_backoff(attempt, None))
|
|
292
|
+
attempt += 1
|
|
293
|
+
continue
|
|
294
|
+
raise APIConnectionError(f"could not reach {url}: {exc}") from exc
|
|
295
|
+
|
|
296
|
+
async def _open_stream(self, body, workspace_id=None):
|
|
297
|
+
"""Return ``(byte_aiterator, aclose)`` for an SSE response."""
|
|
298
|
+
aiohttp = _require_aiohttp()
|
|
299
|
+
session = await self._ensure_session()
|
|
300
|
+
ws = workspace_id or await self.ensure_workspace()
|
|
301
|
+
token = await self._token()
|
|
302
|
+
url = f"{self.base_url}/api/v1/messages"
|
|
303
|
+
headers = self._headers(workspace_id=ws, json_body=True, stream=True, token=token)
|
|
304
|
+
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
305
|
+
try:
|
|
306
|
+
resp = await session.post(url, data=json.dumps(body), headers=headers, timeout=timeout)
|
|
307
|
+
except asyncio.TimeoutError as exc:
|
|
308
|
+
raise APITimeoutError(f"request to {url} timed out") from exc
|
|
309
|
+
except aiohttp.ClientError as exc:
|
|
310
|
+
raise APIConnectionError(f"could not reach {url}: {exc}") from exc
|
|
311
|
+
if resp.status >= 400:
|
|
312
|
+
err = await self._read_error_body(resp)
|
|
313
|
+
rid = self._request_id(resp.headers)
|
|
314
|
+
resp.release()
|
|
315
|
+
raise error_from_status(resp.status, err, rid)
|
|
316
|
+
|
|
317
|
+
async def _aclose():
|
|
318
|
+
try:
|
|
319
|
+
await resp.release()
|
|
320
|
+
except Exception:
|
|
321
|
+
resp.close()
|
|
322
|
+
|
|
323
|
+
return resp.content.iter_any(), _aclose
|
|
324
|
+
|
|
325
|
+
async def stream_events(self, body, workspace_id=None) -> AsyncIterator[Event]:
|
|
326
|
+
byte_aiter, aclose = await self._open_stream(body, workspace_id)
|
|
327
|
+
try:
|
|
328
|
+
async for frame in async_sse_frames(byte_aiter):
|
|
329
|
+
yield Event(frame)
|
|
330
|
+
finally:
|
|
331
|
+
await aclose()
|
|
332
|
+
|
|
333
|
+
# ── workspace + health ────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async def ensure_workspace(self) -> Optional[str]:
|
|
336
|
+
"""Resolve workspace for ``X-Workspace-Id``.
|
|
337
|
+
|
|
338
|
+
Auth0: ``POST /api/auth/login``. Workspace API key: return None so the
|
|
339
|
+
gateway injects the key's bound workspace (LLD-20 T-20.1.3).
|
|
340
|
+
"""
|
|
341
|
+
if self._workspace_id:
|
|
342
|
+
return self._workspace_id
|
|
343
|
+
if self._auth.uses_api_key:
|
|
344
|
+
return None
|
|
345
|
+
try:
|
|
346
|
+
me, _ = await self.request("POST", "/api/auth/login", body={})
|
|
347
|
+
me = me or {}
|
|
348
|
+
except APIError as e:
|
|
349
|
+
if e.status_code == 403 and e.code == "user_not_provisioned":
|
|
350
|
+
raise VioletError(
|
|
351
|
+
"Signed in to Auth0, but this identity has no Violet account "
|
|
352
|
+
"(user_not_provisioned). Call client.auth.signup() to provision "
|
|
353
|
+
"it; a usable workspace also requires a paid subscription."
|
|
354
|
+
) from None
|
|
355
|
+
raise
|
|
356
|
+
ws = me.get("default_workspace") or me.get("defaultWorkspace")
|
|
357
|
+
if isinstance(ws, dict):
|
|
358
|
+
ws = ws.get("id") or ws.get("workspace_id") or ws.get("workspaceId")
|
|
359
|
+
if isinstance(ws, str) and ws:
|
|
360
|
+
self._workspace_id = ws
|
|
361
|
+
return ws
|
|
362
|
+
raise VioletError(
|
|
363
|
+
"Signed in, but your account has no workspace (default_workspace is "
|
|
364
|
+
"null). Create/subscribe a workspace in the Violet web app, or pass "
|
|
365
|
+
"workspace_id=… / set WORKSPACE_ID."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
async def health(self) -> bool:
|
|
369
|
+
aiohttp = _require_aiohttp()
|
|
370
|
+
session = await self._ensure_session()
|
|
371
|
+
for path in ("/api/health", "/health", "/healthz", "/api/v1/health"):
|
|
372
|
+
try:
|
|
373
|
+
async with session.get(
|
|
374
|
+
f"{self.base_url}{path}",
|
|
375
|
+
headers={"User-Agent": self.user_agent, "Accept": "application/json"},
|
|
376
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
377
|
+
) as resp:
|
|
378
|
+
if resp.status == 200:
|
|
379
|
+
return True
|
|
380
|
+
except Exception:
|
|
381
|
+
continue
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── Async stream manager (async context manager) ──────────────────────────────
|
|
386
|
+
|
|
387
|
+
class _AsyncStreamManager:
|
|
388
|
+
def __init__(self, client: AsyncViolet, body: dict, workspace_id):
|
|
389
|
+
self._client = client
|
|
390
|
+
self._body = body
|
|
391
|
+
self._workspace_id = workspace_id
|
|
392
|
+
self._stream: Optional[AsyncMessageStream] = None
|
|
393
|
+
|
|
394
|
+
async def _opener(self):
|
|
395
|
+
return await self._client._open_stream(self._body, self._workspace_id)
|
|
396
|
+
|
|
397
|
+
async def __aenter__(self) -> AsyncMessageStream:
|
|
398
|
+
self._stream = AsyncMessageStream(self._opener)
|
|
399
|
+
return self._stream
|
|
400
|
+
|
|
401
|
+
async def __aexit__(self, *exc) -> None:
|
|
402
|
+
# Drain/close if the consumer didn't finish.
|
|
403
|
+
if self._stream is not None and self._stream._gen is not None:
|
|
404
|
+
try:
|
|
405
|
+
await self._stream._gen.aclose()
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
def __aiter__(self):
|
|
410
|
+
# allow `async for ev in client.messages.stream(...)` without `async with`
|
|
411
|
+
return AsyncMessageStream(self._opener).__aiter__()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ── Async resources ───────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
def _apply_raw(body: dict, per_call: Optional[bool], client: AsyncViolet) -> None:
|
|
417
|
+
rp = per_call if per_call is not None else client.raw_passthrough
|
|
418
|
+
if rp:
|
|
419
|
+
body["raw_passthrough"] = True
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class _AsyncMessages:
|
|
423
|
+
def __init__(self, client: AsyncViolet):
|
|
424
|
+
self._client = client
|
|
425
|
+
|
|
426
|
+
async def create(self, *, model, max_tokens=1024, messages, system=None, tools=None,
|
|
427
|
+
tool_choice=None, stream=False, stop_sequences=None, metadata=None,
|
|
428
|
+
workspace_id=None, raw_passthrough=None, **extra):
|
|
429
|
+
if stream:
|
|
430
|
+
raise VioletError("use client.messages.stream(...) for streaming, not create(stream=True)")
|
|
431
|
+
body = _message_body(model=model, max_tokens=max_tokens, messages=messages, system=system,
|
|
432
|
+
tools=tools, tool_choice=tool_choice, stop_sequences=stop_sequences,
|
|
433
|
+
metadata=metadata, stream=False, extra=extra)
|
|
434
|
+
_apply_raw(body, raw_passthrough, self._client)
|
|
435
|
+
data, rid = await self._client.request("POST", "/api/v1/messages", body=body,
|
|
436
|
+
workspace_id=workspace_id, need_workspace=True)
|
|
437
|
+
return Message(data, request_id=rid)
|
|
438
|
+
|
|
439
|
+
def stream(self, *, model, max_tokens=1024, messages, system=None, tools=None,
|
|
440
|
+
tool_choice=None, stop_sequences=None, metadata=None,
|
|
441
|
+
workspace_id=None, raw_passthrough=None, **extra) -> _AsyncStreamManager:
|
|
442
|
+
body = _message_body(model=model, max_tokens=max_tokens, messages=messages, system=system,
|
|
443
|
+
tools=tools, tool_choice=tool_choice, stop_sequences=stop_sequences,
|
|
444
|
+
metadata=metadata, stream=True, extra=extra)
|
|
445
|
+
_apply_raw(body, raw_passthrough, self._client)
|
|
446
|
+
return _AsyncStreamManager(self._client, body, workspace_id)
|
|
447
|
+
|
|
448
|
+
async def count_tokens(self, *, model, messages, system=None, tools=None, workspace_id=None, **extra) -> TokenCount:
|
|
449
|
+
body: dict = {"model": model, "messages": messages, **extra}
|
|
450
|
+
if system is not None:
|
|
451
|
+
body["system"] = system
|
|
452
|
+
if tools is not None:
|
|
453
|
+
body["tools"] = tools
|
|
454
|
+
data, _ = await self._client.request("POST", "/api/v1/messages/count_tokens", body=body,
|
|
455
|
+
workspace_id=workspace_id, need_workspace=True)
|
|
456
|
+
return TokenCount(data if isinstance(data, dict) else {})
|
|
457
|
+
|
|
458
|
+
async def parse(self, *, output_format, **kwargs) -> Message:
|
|
459
|
+
schema, model_cls = _resolve_schema(output_format)
|
|
460
|
+
kwargs.setdefault("max_tokens", 1024)
|
|
461
|
+
kwargs["output_config"] = {"format": {"type": "json_schema", "schema": schema}}
|
|
462
|
+
msg = await self.create(**kwargs)
|
|
463
|
+
text = "".join(b.text for b in msg.content if b.type == "text")
|
|
464
|
+
try:
|
|
465
|
+
parsed = json.loads(text) if text else None
|
|
466
|
+
except json.JSONDecodeError:
|
|
467
|
+
parsed = None
|
|
468
|
+
if parsed is not None and model_cls is not None:
|
|
469
|
+
validate = getattr(model_cls, "model_validate", None)
|
|
470
|
+
parsed = validate(parsed) if validate else model_cls(**parsed)
|
|
471
|
+
object.__setattr__(msg, "parsed_output", parsed)
|
|
472
|
+
return msg
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class _AsyncAuth:
|
|
476
|
+
def __init__(self, client: AsyncViolet):
|
|
477
|
+
self._client = client
|
|
478
|
+
|
|
479
|
+
async def login(self) -> dict:
|
|
480
|
+
data, _ = await self._client.request("POST", "/api/auth/login", body={})
|
|
481
|
+
return data
|
|
482
|
+
|
|
483
|
+
async def signup(self) -> dict:
|
|
484
|
+
data, _ = await self._client.request("POST", "/api/auth/signup", body={})
|
|
485
|
+
return data
|
|
486
|
+
|
|
487
|
+
async def profile(self) -> dict:
|
|
488
|
+
data, _ = await self._client.request("GET", "/api/auth/profile")
|
|
489
|
+
return data
|
|
490
|
+
|
|
491
|
+
async def logout(self, *, revoke=True, browser=False, return_to=None) -> None:
|
|
492
|
+
await asyncio.to_thread(self._client._auth.logout, revoke=revoke, browser=browser, return_to=return_to)
|
|
493
|
+
self._client._workspace_id = None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class _AsyncUsage:
|
|
497
|
+
def __init__(self, client: AsyncViolet):
|
|
498
|
+
self._client = client
|
|
499
|
+
|
|
500
|
+
async def get(self, *, workspace_id=None) -> UsageReport:
|
|
501
|
+
data, _ = await self._client.request("GET", "/api/v1/usage/me",
|
|
502
|
+
workspace_id=workspace_id, need_workspace=True)
|
|
503
|
+
return UsageReport(_unwrap(data))
|