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 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))