saia-python 0.7.0__py3-none-any.whl → 0.9.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.
saia_python/__init__.py CHANGED
@@ -18,6 +18,12 @@ import concurrent.futures
18
18
  from importlib.metadata import PackageNotFoundError, version
19
19
 
20
20
  from ._http import RetryPolicy
21
+ from ._payloads import (
22
+ INFERENCE_SERVICE,
23
+ apply_arcana_fields,
24
+ arcana_chat_headers,
25
+ build_chat_body,
26
+ )
21
27
  from ._streaming import SSEStream
22
28
  from .arcana_references import (
23
29
  ArcanaReference,
@@ -40,7 +46,7 @@ from .client import SAIAClient
40
46
  from .documents import ConversionImage, ConversionResult
41
47
  from .exceptions import APIError, AuthenticationError, RateLimitError, SAIAError
42
48
  from .openai_compat import create_openai_client
43
- from .rate_limits import RateLimitInfo, parse_rate_limits
49
+ from .rate_limits import RateLimitInfo, format_rate_limit_error, parse_rate_limits
44
50
  from .responses import text_of
45
51
  from .tokenizer import (
46
52
  DEFAULT_TOKENIZER_DIR,
@@ -95,6 +101,12 @@ __all__ = [
95
101
  # Rate limits
96
102
  "RateLimitInfo",
97
103
  "parse_rate_limits",
104
+ "format_rate_limit_error",
105
+ # Request builders (pure — shared by sync, async, and external gateways)
106
+ "build_chat_body",
107
+ "apply_arcana_fields",
108
+ "arcana_chat_headers",
109
+ "INFERENCE_SERVICE",
98
110
  # Response helpers
99
111
  "text_of",
100
112
  "SSEStream",
@@ -142,6 +154,23 @@ __all__ = [
142
154
  "ConversionImage",
143
155
  ]
144
156
 
157
+ # Async API (the ``[async]`` extra: ``pip install saia-python[async]``). Exposed
158
+ # lazily so importing ``saia_python`` never pulls ``httpx`` for sync-only users;
159
+ # ``from saia_python import AsyncSAIAClient`` still works when httpx is present.
160
+ # Kept OUT of ``__all__`` so ``from saia_python import *`` stays httpx-free.
161
+ _ASYNC_EXPORTS = frozenset(
162
+ {"AsyncSAIAClient", "AsyncChatService", "AsyncArcanaService", "AsyncModelsService"}
163
+ )
164
+
165
+
166
+ def __getattr__(name: str):
167
+ """Lazily resolve the async client from :mod:`saia_python.aio` on access."""
168
+ if name in _ASYNC_EXPORTS:
169
+ from . import aio
170
+
171
+ return getattr(aio, name)
172
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
173
+
145
174
 
146
175
  def _make_client(api_key: str | None = None, base_url: str | None = None) -> SAIAClient:
147
176
  kwargs: dict = {}
@@ -0,0 +1,127 @@
1
+ """Async HTTP plumbing — the ``httpx.AsyncClient`` twin of :mod:`saia_python._http`.
2
+
3
+ Mirrors :func:`~saia_python._http.execute` and
4
+ :func:`~saia_python._http.post_chat_completion` over ``httpx.AsyncClient``,
5
+ **reusing the pure retry brains** (:class:`~saia_python._http.RetryPolicy`,
6
+ ``_plan``, ``_jitter``) and :func:`~saia_python.rate_limits.parse_rate_limits`
7
+ unchanged — so the sync and async paths can never drift on rate-limit handling.
8
+ Only the socket-touching parts (``await client.request(...)`` /
9
+ ``await resp.aclose()`` / ``await asyncio.sleep(...)``) are re-implemented.
10
+
11
+ This module never imports ``httpx`` at runtime — it only *calls methods* on a
12
+ client object the caller supplies, so it stays import-safe without the
13
+ ``[async]`` extra and is trivially testable with a fake client. Construct the
14
+ client via :class:`saia_python.aio.AsyncSAIAClient` (or pass your own
15
+ ``httpx.AsyncClient``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from collections.abc import Awaitable, Callable
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from ._http import RetryPolicy, _jitter, _plan
26
+ from .exceptions import raise_for_status
27
+ from .rate_limits import parse_rate_limits
28
+
29
+ if TYPE_CHECKING:
30
+ import httpx
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ #: An awaitable ``sleep(seconds)`` — ``asyncio.sleep`` in production; tests pass
35
+ #: a recorder so they never actually block.
36
+ AsyncSleep = Callable[..., Awaitable[Any]]
37
+
38
+
39
+ async def aexecute(
40
+ client: httpx.AsyncClient,
41
+ method: str,
42
+ url: str,
43
+ *,
44
+ policy: RetryPolicy,
45
+ idempotent: bool,
46
+ sleep: AsyncSleep = asyncio.sleep,
47
+ **kwargs: Any,
48
+ ) -> httpx.Response:
49
+ """Issue a request under a transport policy and return the response.
50
+
51
+ The async analogue of :func:`saia_python._http.execute`: dispatches
52
+ ``getattr(client, method)(url, **kwargs)`` (``method`` is the lowercase verb
53
+ — ``"post"`` / ``"get"`` — matching the sync seam) and, on HTTP 429 that the
54
+ ``policy`` permits, waits per :func:`~saia_python._http._plan` and retries.
55
+
56
+ Like the sync version it returns the **raw response** unchanged on success
57
+ *or* on give-up, so the caller's
58
+ :func:`~saia_python.exceptions.raise_for_status` still raises
59
+ :class:`~saia_python.RateLimitError` when retry is off, the budget is spent,
60
+ or the window must not be waited on. Only status + headers are inspected —
61
+ never the body — so a give-up never consumes the response. Streaming is a
62
+ separate seam (:class:`saia_python.aio.AsyncSSEStream`), because ``httpx``
63
+ exposes a streamed body only inside ``client.stream(...)``.
64
+ """
65
+ attempt = 0
66
+ while True:
67
+ resp = await getattr(client, method)(url, **kwargs)
68
+ if resp.status_code != 429 or not policy.applies(idempotent):
69
+ return resp
70
+ wait = _plan(parse_rate_limits(resp.headers), policy, attempt)
71
+ if wait is None:
72
+ return resp
73
+ await resp.aclose()
74
+ attempt += 1
75
+ wait += _jitter(policy)
76
+ log.info("SAIA rate limit (429) — waiting %.1fs before retry %d", wait, attempt)
77
+ await sleep(wait)
78
+
79
+
80
+ async def apost_chat_completion(
81
+ client: httpx.AsyncClient,
82
+ url: str,
83
+ body: dict,
84
+ *,
85
+ headers: dict | None = None,
86
+ stream: bool = False,
87
+ policy: RetryPolicy | None = None,
88
+ sleep: AsyncSleep = asyncio.sleep,
89
+ ) -> dict | Any:
90
+ """POST a chat-completion request and normalise the response (async).
91
+
92
+ The async twin of :func:`saia_python._http.post_chat_completion`. For
93
+ ``stream=False`` it returns the response dict with an extra
94
+ ``"_rate_limits"`` key; for ``stream=True`` it returns a connected
95
+ :class:`~saia_python.aio.AsyncSSEStream` (imported lazily to avoid a cycle),
96
+ with the initial-429 retry already applied *before* the stream is exposed —
97
+ never mid-stream, exactly like the sync path.
98
+ """
99
+ policy = policy if policy is not None else RetryPolicy()
100
+ if stream:
101
+ from ._async_streaming import AsyncSSEStream
102
+
103
+ stream_body = {**body, "stream": True}
104
+ stream_headers = {**(headers or {}), "Accept": "text/event-stream"}
105
+ return await AsyncSSEStream.open(
106
+ client,
107
+ url,
108
+ json=stream_body,
109
+ headers=stream_headers,
110
+ policy=policy,
111
+ sleep=sleep,
112
+ )
113
+
114
+ resp = await aexecute(
115
+ client,
116
+ "post",
117
+ url,
118
+ policy=policy,
119
+ idempotent=True,
120
+ sleep=sleep,
121
+ json=body,
122
+ headers=headers,
123
+ )
124
+ raise_for_status(resp)
125
+ result = resp.json()
126
+ result["_rate_limits"] = parse_rate_limits(resp.headers).to_dict()
127
+ return result
@@ -0,0 +1,157 @@
1
+ """Async SSE streaming — the ``httpx.AsyncClient`` twin of :mod:`saia_python._streaming`.
2
+
3
+ ``httpx`` only exposes a streamed body inside an ``async with client.stream(...)``
4
+ context, so unlike the sync :class:`~saia_python._streaming.SSEStream` (which
5
+ wraps an already-open ``requests`` response) this class **owns** that context
6
+ manager: :meth:`AsyncSSEStream.open` enters it — retrying an initial 429 *before*
7
+ any body is exposed, exactly like the sync path — and :meth:`aclose` / the
8
+ ``async with`` protocol exits it.
9
+
10
+ Like :mod:`saia_python._async_http`, this module never imports ``httpx`` at
11
+ runtime; it only calls methods on the client / response the caller supplies,
12
+ staying import-safe without the ``[async]`` extra and trivially fakeable in tests.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ from collections.abc import AsyncIterator
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from ._async_http import AsyncSleep
24
+ from ._http import RetryPolicy, _jitter, _plan
25
+ from .exceptions import raise_for_status
26
+ from .rate_limits import parse_rate_limits
27
+
28
+ if TYPE_CHECKING:
29
+ import httpx
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+
34
+ class AsyncSSEStream:
35
+ """Async iterable over the SSE chunks of an ``httpx`` streaming response.
36
+
37
+ The async twin of :class:`~saia_python._streaming.SSEStream`. Two
38
+ consumption modes — pick **one**, because a streamed body can only be read
39
+ once:
40
+
41
+ * ``async for chunk in stream`` — decoded ``dict`` chunks (the high-level
42
+ API). Surfaces a typed error (:class:`~saia_python.RateLimitError` with an
43
+ informative message on 429, etc.) *before* the first chunk if the upstream
44
+ status is an error.
45
+ * ``async for line in stream.aiter_lines()`` — the raw decoded ``str`` SSE
46
+ lines. Does **not** raise, so a gateway can frame upstream errors itself
47
+ (this is what the AVOR adapter uses to keep its verbatim ``[DONE]`` /
48
+ non-``data:`` line passthrough).
49
+
50
+ Either way, use it as an async context manager (``async with stream:``) or
51
+ call :meth:`aclose` so the upstream connection is released.
52
+
53
+ Attributes:
54
+ status_code: The final upstream status code (after any retry).
55
+ rate_limits: A JSON-serializable dict of the response's rate-limit
56
+ headers (available immediately — headers arrive before the body).
57
+ """
58
+
59
+ def __init__(self, cm: Any, response: httpx.Response):
60
+ self._cm: Any = cm
61
+ self._response = response
62
+ self.status_code: int = response.status_code
63
+ self.rate_limits: dict = parse_rate_limits(response.headers).to_dict()
64
+
65
+ @classmethod
66
+ async def open(
67
+ cls,
68
+ client: httpx.AsyncClient,
69
+ url: str,
70
+ *,
71
+ json: dict,
72
+ headers: dict | None = None,
73
+ policy: RetryPolicy | None = None,
74
+ sleep: AsyncSleep = asyncio.sleep,
75
+ ) -> AsyncSSEStream:
76
+ """Open the stream, retrying an initial 429 before exposing the body.
77
+
78
+ Mirrors :func:`saia_python._http.execute`'s retry loop, but over
79
+ ``client.stream(...)``: it enters the context, and on a retryable 429
80
+ exits it (releasing the socket) and re-issues after the planned wait.
81
+ Only status + headers are inspected, so the streamed body is never
82
+ consumed by the retry decision. Returns a connected stream whose
83
+ ``status_code`` reflects the final attempt (which may still be an error
84
+ the caller inspects).
85
+ """
86
+ policy = policy if policy is not None else RetryPolicy()
87
+ attempt = 0
88
+ while True:
89
+ cm = client.stream("POST", url, json=json, headers=headers)
90
+ response = await cm.__aenter__()
91
+ if response.status_code != 429 or not policy.applies(True):
92
+ return cls(cm, response)
93
+ wait = _plan(parse_rate_limits(response.headers), policy, attempt)
94
+ if wait is None:
95
+ return cls(cm, response)
96
+ await cm.__aexit__(None, None, None)
97
+ attempt += 1
98
+ wait += _jitter(policy)
99
+ log.info(
100
+ "SAIA rate limit (429) — waiting %.1fs before retry %d", wait, attempt
101
+ )
102
+ await sleep(wait)
103
+
104
+ async def aiter_lines(self) -> AsyncIterator[str]:
105
+ """Yield the raw decoded SSE lines (no dict parsing, no raise).
106
+
107
+ The low-level surface: the caller sees every line verbatim (``data:``
108
+ payloads, ``data: [DONE]``, blank event terminators) and decides how to
109
+ frame errors and what to forward. Releases the connection when the
110
+ consumer stops, whether it finished, broke early, or was cancelled.
111
+ """
112
+ try:
113
+ async for raw in self._response.aiter_lines():
114
+ yield raw if isinstance(raw, str) else raw.decode("utf-8")
115
+ finally:
116
+ await self.aclose()
117
+
118
+ async def __aiter__(self) -> AsyncIterator[dict]:
119
+ """Yield decoded ``dict`` chunks (high-level; raises on an error status).
120
+
121
+ Copies the sync :func:`~saia_python._streaming.iter_sse` decision logic
122
+ verbatim: skip non-``data:`` lines, stop on ``[DONE]``, ``json.loads``
123
+ each payload, silently skip an unparseable one.
124
+ """
125
+ if self.status_code >= 400:
126
+ await self._response.aread()
127
+ raise_for_status(self._response)
128
+ try:
129
+ async for raw in self._response.aiter_lines():
130
+ line = raw if isinstance(raw, str) else raw.decode("utf-8")
131
+ if not line or not line.startswith("data:"):
132
+ continue
133
+ payload = line[len("data:") :].strip()
134
+ if payload == "[DONE]":
135
+ return
136
+ try:
137
+ yield json.loads(payload)
138
+ except json.JSONDecodeError:
139
+ continue
140
+ finally:
141
+ await self.aclose()
142
+
143
+ async def aread(self) -> bytes:
144
+ """Read and return the full upstream body (used to frame an error)."""
145
+ return await self._response.aread()
146
+
147
+ async def aclose(self) -> None:
148
+ """Release the upstream connection. Idempotent."""
149
+ if self._cm is not None:
150
+ cm, self._cm = self._cm, None
151
+ await cm.__aexit__(None, None, None)
152
+
153
+ async def __aenter__(self) -> AsyncSSEStream:
154
+ return self
155
+
156
+ async def __aexit__(self, *exc: object) -> None:
157
+ await self.aclose()
@@ -0,0 +1,81 @@
1
+ """Pure request-body / header builders shared across transports.
2
+
3
+ Transport-free — no ``Session``, no ``httpx.AsyncClient``, no I/O — so these
4
+ helpers are reused **verbatim** by the sync services (:mod:`saia_python.chat`,
5
+ :mod:`saia_python.arcana`), the async services (:mod:`saia_python.aio`), and
6
+ external gateways that assemble the request themselves (the AVOR adapter builds
7
+ its own body so it can inject a per-conversation system prompt, then reuses
8
+ :func:`apply_arcana_fields` to keep the ARCANA injection identical to ours).
9
+
10
+ Keeping the ARCANA injection in ONE place matters: GWDG only routes a request
11
+ through the retrieval pipeline when **all three** of ``enable-tools`` + the
12
+ ``arcana.id`` body field *and* the ``inference-service`` header are present.
13
+ Drop any one and the request still returns 200 — but with no retrieval. That
14
+ invariant now lives in :func:`apply_arcana_fields` + :func:`arcana_chat_headers`
15
+ instead of being retyped at every call site.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ #: The GWDG gateway value that opts a chat request into the ARCANA retrieval
23
+ #: pipeline. Sent as the ``inference-service`` header (see
24
+ #: :func:`arcana_chat_headers`); without it retrieval never fires.
25
+ INFERENCE_SERVICE = "saia-openai-gateway"
26
+
27
+
28
+ def build_chat_body(
29
+ model: str,
30
+ messages: list[dict],
31
+ *,
32
+ temperature: float | None = None,
33
+ top_p: float | None = None,
34
+ max_tokens: int | None = None,
35
+ **kwargs: Any,
36
+ ) -> dict:
37
+ """Assemble an OpenAI-shaped ``/chat/completions`` request body.
38
+
39
+ The sampling knobs are omitted from the body when ``None`` (so the server's
40
+ own defaults apply) rather than being sent as ``null``. Extra ``kwargs`` are
41
+ merged verbatim, letting callers pass ``stop``, ``stream``, ``seed``, etc.
42
+ """
43
+ body: dict = {"model": model, "messages": messages, **kwargs}
44
+ if temperature is not None:
45
+ body["temperature"] = temperature
46
+ if top_p is not None:
47
+ body["top_p"] = top_p
48
+ if max_tokens is not None:
49
+ body["max_tokens"] = max_tokens
50
+ return body
51
+
52
+
53
+ def apply_arcana_fields(body: dict, arcana_id: str) -> dict:
54
+ """Return ``body`` with the ARCANA RAG **body** fields injected.
55
+
56
+ Adds ``enable-tools: true`` and ``arcana: {"id": arcana_id}``. Returns a
57
+ **new** dict (the input is left unmutated) with the ARCANA fields applied
58
+ last, so they cannot be silently clobbered by an earlier body key. This is
59
+ the body half of the three-part retrieval invariant; pair it with
60
+ :func:`arcana_chat_headers` (or set the ``inference-service`` header
61
+ yourself) for the header half.
62
+ """
63
+ return {**body, "enable-tools": True, "arcana": {"id": arcana_id}}
64
+
65
+
66
+ def arcana_chat_headers(api_key: str, *, extra: dict | None = None) -> dict:
67
+ """Build the header set for an ARCANA chat call.
68
+
69
+ ``Bearer`` auth + ``Accept: application/json`` + the ``inference-service``
70
+ gateway header (the header half of the retrieval invariant). ``extra`` is
71
+ merged last, so a caller can add a correlation id (e.g. ``X-Request-ID``)
72
+ without losing the required headers.
73
+ """
74
+ headers = {
75
+ "Authorization": f"Bearer {api_key}",
76
+ "Accept": "application/json",
77
+ "inference-service": INFERENCE_SERVICE,
78
+ }
79
+ if extra:
80
+ headers.update(extra)
81
+ return headers
saia_python/aio.py ADDED
@@ -0,0 +1,464 @@
1
+ """Async client for the GWDG SAIA platform — the ``httpx`` twin of the sync API.
2
+
3
+ Requires the ``[async]`` extra::
4
+
5
+ pip install saia-python[async]
6
+
7
+ Usage mirrors :class:`~saia_python.SAIAClient`, with ``await`` on the network
8
+ calls and ``async for`` over a stream::
9
+
10
+ from saia_python.aio import AsyncSAIAClient
11
+
12
+ async with AsyncSAIAClient() as client:
13
+ # non-streaming RAG chat
14
+ answer = await client.arcana.chat(
15
+ model="openai-gpt-oss-120b",
16
+ messages=[{"role": "user", "content": "..."}],
17
+ arcana_id="owner/kb",
18
+ )
19
+ # streaming plain chat
20
+ stream = await client.chat.completions(
21
+ model="...", messages=[...], stream=True,
22
+ )
23
+ async for chunk in stream:
24
+ ...
25
+
26
+ **Scope.** The async layer covers the **data plane** — chat completions and
27
+ ARCANA RAG chat (streaming + non-streaming), the rate-limit
28
+ :class:`~saia_python.RetryPolicy` as a per-call ``retry`` keyword, rate-limit
29
+ surfacing, and an informative 429 error when retry is off — plus the
30
+ lightweight read-only control-plane calls (``models``, arcana
31
+ ``version``/``heartbeat``/``list``/``get``, ``health_check``). File
32
+ management (upload / index / sync), voice transcription, and document
33
+ conversion stay **sync-only** on :class:`~saia_python.SAIAClient`: batch/admin
34
+ work with blocking file I/O and no concurrency benefit. See
35
+ ``docs/adr/0007-native-async-transport.md``.
36
+
37
+ The retry semantics are identical to the sync path (same
38
+ :class:`~saia_python.RetryPolicy`, ``_plan``, jitter — imported, not copied), so
39
+ the two transports cannot drift.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from typing import TYPE_CHECKING, Any
45
+
46
+ from ._async_http import aexecute, apost_chat_completion
47
+ from ._async_streaming import AsyncSSEStream
48
+ from ._http import RetryPolicy, coerce_retry, resolve_retry
49
+ from ._payloads import (
50
+ apply_arcana_fields,
51
+ arcana_chat_headers,
52
+ build_chat_body,
53
+ )
54
+ from .auth import resolve_credentials
55
+ from .exceptions import raise_for_status
56
+ from .rate_limits import RateLimitInfo, parse_rate_limits
57
+
58
+ if TYPE_CHECKING:
59
+ import httpx
60
+
61
+ _ARCANA_PATH = "/arcanas/api/v1"
62
+
63
+ # Data-plane default: a long read so a legitimately slow RAG/chat answer (which
64
+ # can run for minutes) is not truncated. The read-only control-plane calls pass
65
+ # their own short per-request cap instead. Mirrors the sync split (the chat path
66
+ # is exempt from the control-plane timeout). httpx wants a float/httpx.Timeout,
67
+ # not the requests-style (connect, read) tuple, so these are plain floats.
68
+ _DATA_PLANE_READ_TIMEOUT = 300.0
69
+ _CONTROL_PLANE_TIMEOUT = 60.0
70
+
71
+ __all__ = [
72
+ "AsyncSAIAClient",
73
+ "AsyncChatService",
74
+ "AsyncArcanaService",
75
+ "AsyncModelsService",
76
+ "AsyncSSEStream",
77
+ "aexecute",
78
+ "apost_chat_completion",
79
+ ]
80
+
81
+
82
+ def _require_httpx() -> Any:
83
+ """Import ``httpx`` or raise a pointed error naming the ``[async]`` extra."""
84
+ try:
85
+ import httpx
86
+ except ImportError as exc: # pragma: no cover - trivial import guard
87
+ raise ImportError(
88
+ "The async client requires the optional 'httpx' dependency. "
89
+ "Install it with:\n pip install saia-python[async]"
90
+ ) from exc
91
+ return httpx
92
+
93
+
94
+ class AsyncChatService:
95
+ """Async access to ``/chat/completions`` (the twin of :class:`~saia_python.chat.ChatService`)."""
96
+
97
+ def __init__(
98
+ self,
99
+ client: httpx.AsyncClient,
100
+ base_url: str,
101
+ *,
102
+ retry: RetryPolicy | bool | None = None,
103
+ ):
104
+ self._client = client
105
+ self._base_url = base_url
106
+ self._retry = coerce_retry(retry)
107
+
108
+ async def completions(
109
+ self,
110
+ model: str,
111
+ messages: list[dict],
112
+ *,
113
+ temperature: float | None = None,
114
+ top_p: float | None = None,
115
+ max_tokens: int | None = None,
116
+ stream: bool = False,
117
+ retry: RetryPolicy | bool | None = None,
118
+ **kwargs: Any,
119
+ ) -> dict | AsyncSSEStream:
120
+ """Send a chat completion request. See :meth:`ChatService.completions`.
121
+
122
+ With ``stream=False`` returns the response dict (plus a ``_rate_limits``
123
+ key); with ``stream=True`` returns an :class:`AsyncSSEStream` to iterate.
124
+ ``retry`` overrides the service policy for this call (``False`` fails
125
+ fast on a 429 with an informative :class:`~saia_python.RateLimitError`).
126
+ """
127
+ body = build_chat_body(
128
+ model,
129
+ messages,
130
+ temperature=temperature,
131
+ top_p=top_p,
132
+ max_tokens=max_tokens,
133
+ **kwargs,
134
+ )
135
+ return await apost_chat_completion(
136
+ self._client,
137
+ f"{self._base_url}/chat/completions",
138
+ body,
139
+ stream=stream,
140
+ policy=resolve_retry(self._retry, retry),
141
+ )
142
+
143
+ def __repr__(self) -> str:
144
+ return f"AsyncChatService(base_url={self._base_url!r})"
145
+
146
+
147
+ class AsyncArcanaService:
148
+ """Async access to the ARCANA data plane + read-only control plane.
149
+
150
+ Covers RAG :meth:`chat` (the point of the async layer) plus the cheap
151
+ read-only management calls (:meth:`version`, :meth:`heartbeat`,
152
+ :meth:`user_info`, :meth:`list`, :meth:`get`). Uploads, indexing, and
153
+ directory sync stay on the sync :class:`~saia_python.arcana.ArcanaService`.
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ client: httpx.AsyncClient,
159
+ base_url: str,
160
+ api_key: str,
161
+ *,
162
+ timeout: float | None = _CONTROL_PLANE_TIMEOUT,
163
+ retry: RetryPolicy | bool | None = None,
164
+ ):
165
+ self._client = client
166
+ self._base_url = base_url
167
+ self._arcana_base = f"{base_url}{_ARCANA_PATH}"
168
+ self._api_key = api_key
169
+ self._timeout = timeout
170
+ self._retry = coerce_retry(retry)
171
+
172
+ def _headers(self, **extra: str) -> dict:
173
+ # Control plane uses the raw key (no Bearer prefix), unlike chat().
174
+ return {"Authorization": self._api_key, "Accept": "application/json", **extra}
175
+
176
+ async def chat(
177
+ self,
178
+ model: str,
179
+ messages: list[dict],
180
+ arcana_id: str,
181
+ *,
182
+ temperature: float | None = None,
183
+ max_tokens: int | None = None,
184
+ stream: bool = False,
185
+ retry: RetryPolicy | bool | None = None,
186
+ api_key: str | None = None,
187
+ extra_headers: dict | None = None,
188
+ **kwargs: Any,
189
+ ) -> dict | AsyncSSEStream:
190
+ """Chat with RAG context from an arcana (async twin of :meth:`ArcanaService.chat`).
191
+
192
+ The three-part ARCANA injection (``enable-tools`` + ``arcana.id`` body
193
+ fields and the ``inference-service`` header) comes from the shared
194
+ :func:`~saia_python._payloads.apply_arcana_fields` /
195
+ :func:`~saia_python._payloads.arcana_chat_headers`, so it matches the
196
+ sync :meth:`ArcanaService.chat`. One deliberate difference: the injection
197
+ is applied **last**, so the retrieval fields cannot be disabled by a
198
+ stray caller key (the sync path lets a ``**kwargs`` override win) — the
199
+ Rule #6-safe behaviour.
200
+
201
+ Args:
202
+ api_key: Override the instance key for this call — lets one pooled
203
+ async client serve many users (each request authenticated
204
+ separately), which a shared multi-user gateway needs.
205
+ extra_headers: Extra headers merged into the request (e.g. an
206
+ ``X-Request-ID`` for cross-referencing upstream logs).
207
+ """
208
+ body = apply_arcana_fields(
209
+ build_chat_body(
210
+ model,
211
+ messages,
212
+ temperature=temperature,
213
+ max_tokens=max_tokens,
214
+ **kwargs,
215
+ ),
216
+ arcana_id,
217
+ )
218
+ headers = arcana_chat_headers(api_key or self._api_key, extra=extra_headers)
219
+ return await apost_chat_completion(
220
+ self._client,
221
+ f"{self._base_url}/chat/completions",
222
+ body,
223
+ headers=headers,
224
+ stream=stream,
225
+ policy=resolve_retry(self._retry, retry),
226
+ )
227
+
228
+ async def _get_json(
229
+ self, url: str, *, timeout: Any = None, retry: Any = None
230
+ ) -> Any:
231
+ resp = await aexecute(
232
+ self._client,
233
+ "get",
234
+ url,
235
+ policy=resolve_retry(self._retry, retry),
236
+ idempotent=True,
237
+ headers=self._headers(),
238
+ timeout=timeout if timeout is not None else self._timeout,
239
+ )
240
+ raise_for_status(resp)
241
+ return resp.json()
242
+
243
+ async def version(self) -> str:
244
+ """Return the ARCANA API version string."""
245
+ data = await self._get_json(f"{self._arcana_base}/version")
246
+ return data.get("version", "") if isinstance(data, dict) else str(data)
247
+
248
+ async def heartbeat(self) -> bool:
249
+ """Return ``True`` if the ARCANA backend answers 204 (fast, no retry)."""
250
+ try:
251
+ resp = await aexecute(
252
+ self._client,
253
+ "get",
254
+ f"{self._arcana_base}/heartbeat",
255
+ policy=coerce_retry(False),
256
+ idempotent=True,
257
+ headers=self._headers(),
258
+ timeout=10.0,
259
+ )
260
+ except Exception:
261
+ return False
262
+ return resp.status_code == 204
263
+
264
+ async def user_info(self) -> dict:
265
+ """Return the current user's profile + arcana statistics."""
266
+ return await self._get_json(f"{self._arcana_base}/user/me")
267
+
268
+ async def list(self) -> list[dict]:
269
+ """List the caller's arcanas."""
270
+ return await self._get_json(f"{self._arcana_base}/arcana/")
271
+
272
+ async def get(self, name: str) -> dict:
273
+ """Get one arcana by name (accepts ``owner/name`` or ``name``)."""
274
+ from urllib.parse import quote
275
+
276
+ from .arcana import extract_arcana_name
277
+
278
+ short = extract_arcana_name(name)
279
+ return await self._get_json(f"{self._arcana_base}/arcana/{quote(short)}")
280
+
281
+ def __repr__(self) -> str:
282
+ return f"AsyncArcanaService(base_url={self._base_url!r})"
283
+
284
+
285
+ class AsyncModelsService:
286
+ """Async model listing (read-only twin of :class:`~saia_python.models.ModelsService`)."""
287
+
288
+ def __init__(
289
+ self,
290
+ client: httpx.AsyncClient,
291
+ base_url: str,
292
+ *,
293
+ timeout: float | None = _CONTROL_PLANE_TIMEOUT,
294
+ ):
295
+ self._client = client
296
+ self._base_url = base_url
297
+ self._timeout = timeout
298
+
299
+ async def list_raw(self) -> dict:
300
+ """Return the raw ``GET /models`` payload."""
301
+ # Cap the read at the control-plane timeout (not the client-wide
302
+ # data-plane read, which is long for chat) so a wedged /models probe
303
+ # fails fast instead of hanging — mirrors the sync ModelsService.
304
+ resp = await self._client.get(f"{self._base_url}/models", timeout=self._timeout)
305
+ raise_for_status(resp)
306
+ return resp.json()
307
+
308
+ async def list(self) -> list[dict]:
309
+ """Return the list of model dicts (unwrapping the ``{"data": [...]}``)."""
310
+ payload = await self.list_raw()
311
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
312
+ return payload["data"]
313
+ return payload if isinstance(payload, list) else []
314
+
315
+ async def list_ids(self) -> list[str]:
316
+ """Return the model id strings, de-duplicated in first-seen order.
317
+
318
+ Coalesces the same id keys as the sync :meth:`ModelsService.list_ids`
319
+ (``id`` / ``modelId`` / ``model_id`` / ``model`` / ``name`` /
320
+ ``model_name``) so a non-OpenAI-shaped gateway payload still yields ids.
321
+ """
322
+ seen: dict[str, None] = {}
323
+ for model in await self.list():
324
+ if not isinstance(model, dict):
325
+ continue
326
+ mid = (
327
+ model.get("id")
328
+ or model.get("modelId")
329
+ or model.get("model_id")
330
+ or model.get("model")
331
+ or model.get("name")
332
+ or model.get("model_name")
333
+ )
334
+ if isinstance(mid, str) and mid:
335
+ seen.setdefault(mid, None)
336
+ return list(seen)
337
+
338
+ def __repr__(self) -> str:
339
+ return f"AsyncModelsService(base_url={self._base_url!r})"
340
+
341
+
342
+ class AsyncSAIAClient:
343
+ """Async, connection-pooled client for the SAIA data plane.
344
+
345
+ Owns one ``httpx.AsyncClient`` (shared across services for connection
346
+ pooling). Use it as an async context manager so the pool is closed::
347
+
348
+ async with AsyncSAIAClient() as client:
349
+ await client.chat.completions(model="...", messages=[...])
350
+
351
+ Credential + base-URL resolution is identical to
352
+ :class:`~saia_python.SAIAClient` (delegated to the same
353
+ :func:`~saia_python.auth.resolve_credentials`).
354
+ """
355
+
356
+ def __init__(
357
+ self,
358
+ api_key: str | None = None,
359
+ base_url: str | None = None,
360
+ key_file: str | None = None,
361
+ *,
362
+ timeout: Any = None,
363
+ retry: RetryPolicy | bool | None = None,
364
+ ):
365
+ httpx = _require_httpx()
366
+ self._api_key, self._base_url = resolve_credentials(api_key, base_url, key_file)
367
+ self._retry = coerce_retry(retry)
368
+ if timeout is None:
369
+ timeout = httpx.Timeout(
370
+ connect=10.0, read=_DATA_PLANE_READ_TIMEOUT, write=10.0, pool=10.0
371
+ )
372
+ self._client = httpx.AsyncClient(
373
+ timeout=timeout,
374
+ headers={
375
+ "Authorization": f"Bearer {self._api_key}",
376
+ "Accept": "application/json",
377
+ },
378
+ )
379
+ self._chat: AsyncChatService | None = None
380
+ self._arcana: AsyncArcanaService | None = None
381
+ self._models: AsyncModelsService | None = None
382
+
383
+ @property
384
+ def chat(self) -> AsyncChatService:
385
+ """Async chat completions service."""
386
+ if self._chat is None:
387
+ self._chat = AsyncChatService(
388
+ self._client, self._base_url, retry=self._retry
389
+ )
390
+ return self._chat
391
+
392
+ @property
393
+ def arcana(self) -> AsyncArcanaService:
394
+ """Async ARCANA/RAG service (data plane + read-only control plane)."""
395
+ if self._arcana is None:
396
+ self._arcana = AsyncArcanaService(
397
+ self._client, self._base_url, self._api_key, retry=self._retry
398
+ )
399
+ return self._arcana
400
+
401
+ @property
402
+ def models(self) -> AsyncModelsService:
403
+ """Async model listing service."""
404
+ if self._models is None:
405
+ self._models = AsyncModelsService(self._client, self._base_url)
406
+ return self._models
407
+
408
+ async def get_rate_limits(self) -> RateLimitInfo:
409
+ """Fetch current rate-limit status via a lightweight probe.
410
+
411
+ A ``GET /chat/completions`` returns 400 (missing body) but carries the
412
+ rate-limit headers; a 401/403 is surfaced as
413
+ :class:`~saia_python.AuthenticationError`.
414
+ """
415
+ resp = await self._client.get(f"{self._base_url}/chat/completions")
416
+ if resp.status_code in (401, 403):
417
+ raise_for_status(resp)
418
+ return parse_rate_limits(resp.headers)
419
+
420
+ async def arcana_version(self) -> str:
421
+ """Return the ARCANA API version string."""
422
+ return await self.arcana.version()
423
+
424
+ async def arcana_heartbeat(self) -> bool:
425
+ """Return ``True`` if the ARCANA backend is reachable (204)."""
426
+ return await self.arcana.heartbeat()
427
+
428
+ async def health_check(self, *, verbose: bool = False) -> bool | dict:
429
+ """Verify the client can reach + authenticate against the API.
430
+
431
+ Combines ``GET /models`` (auth + chat backend) with the ARCANA
432
+ heartbeat. Returns a bool, or a diagnostic dict with ``verbose=True``.
433
+ """
434
+ details: dict = {
435
+ "base_url": self._base_url,
436
+ "models_ok": False,
437
+ "model_count": 0,
438
+ "arcana_ok": False,
439
+ "error": None,
440
+ }
441
+ try:
442
+ model_ids = await self.models.list_ids()
443
+ details["models_ok"] = True
444
+ details["model_count"] = len(model_ids)
445
+ except Exception as exc:
446
+ details["error"] = f"models: {exc}"
447
+ details["arcana_ok"] = await self.arcana_heartbeat()
448
+ if not details["arcana_ok"] and details["error"] is None:
449
+ details["error"] = "arcana heartbeat returned non-204"
450
+ details["ok"] = details["models_ok"] and details["arcana_ok"]
451
+ return details if verbose else bool(details["ok"])
452
+
453
+ async def aclose(self) -> None:
454
+ """Close the underlying ``httpx.AsyncClient`` (release the pool)."""
455
+ await self._client.aclose()
456
+
457
+ async def __aenter__(self) -> AsyncSAIAClient:
458
+ return self
459
+
460
+ async def __aexit__(self, *exc: object) -> None:
461
+ await self.aclose()
462
+
463
+ def __repr__(self) -> str:
464
+ return f"AsyncSAIAClient(base_url={self._base_url!r})"
saia_python/arcana.py CHANGED
@@ -415,6 +415,49 @@ class ArcanaService:
415
415
 
416
416
  return _json_or_none(resp)
417
417
 
418
+ def recreate(self, name: str, *, update_toml: bool = False) -> dict:
419
+ """Delete an arcana and recreate it **empty with the same ID**.
420
+
421
+ The minimal-call way to wipe an entire arcana: two requests
422
+ (:meth:`delete` + :meth:`create`) regardless of how many files it
423
+ holds, versus one :meth:`delete_file` per file (thousands of calls,
424
+ each its own read-timeout risk while the arcana is busy). The name —
425
+ including any UUID suffix — is preserved verbatim via
426
+ ``create(..., append_uuid=False)``, so a downstream pin on the full
427
+ ``owner/name-uuid`` ID stays valid.
428
+
429
+ Trade-offs versus emptying file-by-file (:meth:`delete_file` in a
430
+ loop, which keeps the container): there is a brief window between the
431
+ two calls where the arcana does **not exist**, and the recreated
432
+ arcana is brand-new — ``created_at``, sharing/permissions and any
433
+ other container settings reset to defaults. Only the name/ID carries
434
+ across.
435
+
436
+ Args:
437
+ name: The arcana name or full ``owner/name`` ID to recreate.
438
+ update_toml: Forwarded to :meth:`create`.
439
+
440
+ Returns:
441
+ The :meth:`create` result (``{"name", "id", "message"}``).
442
+
443
+ Raises:
444
+ APIError: If recreation fails *after* the delete already
445
+ succeeded — the arcana is then gone. The message says so
446
+ explicitly so the operator recreates it before any consumer
447
+ (adapter / manifest pin) points at the ID.
448
+ """
449
+ short = extract_arcana_name(name)
450
+ self.delete(name)
451
+ try:
452
+ return self.create(short, append_uuid=False, update_toml=update_toml)
453
+ except Exception as e: # noqa: BLE001 — re-raise with a louder message
454
+ raise APIError(
455
+ f"arcana {short!r} was DELETED but could not be recreated: {e}. "
456
+ f"The arcana no longer exists — recreate it (e.g. "
457
+ f"create({short!r}, append_uuid=False)) before any consumer "
458
+ f"(adapter / manifest pin) points at its ID."
459
+ ) from e
460
+
418
461
  def list(self) -> list[dict]:
419
462
  """List all available arcanas.
420
463
 
@@ -742,6 +785,70 @@ class ArcanaService:
742
785
  raise_for_status(resp)
743
786
  return _json_or_none(resp)
744
787
 
788
+ def delete_files(
789
+ self,
790
+ name: str,
791
+ file_names: Iterable[str],
792
+ *,
793
+ verbose: bool = False,
794
+ on_result: Callable[[str, dict], None] | None = None,
795
+ ) -> list[dict]:
796
+ """Delete an explicit list of files from an arcana, by name.
797
+
798
+ The batch counterpart to :meth:`delete_file`: hand it the file names
799
+ (as returned by :meth:`list_files`) and it deletes each one,
800
+ capturing a per-file outcome instead of aborting on the first
801
+ failure. Unlike :meth:`delete_directory` no local directory is
802
+ consulted — the names come straight from the caller — so it can
803
+ target files that no longer exist on disk (e.g. a name list from a
804
+ CSV). Pairs with a thin CLI front-end that resolves *which* names to
805
+ delete; this method only does the deleting.
806
+
807
+ Args:
808
+ name: The arcana name or full ``owner/name`` ID.
809
+ file_names: The file names to delete (flat names, as listed by
810
+ :meth:`list_files`). Order is preserved; a repeated name is
811
+ attempted each time (a second delete just reports the
812
+ server's response / 404).
813
+ verbose: If ``True``, print per-file deletion status.
814
+ on_result: Optional callback invoked as ``on_result(file_name,
815
+ entry)`` after each file (``entry`` is that file's
816
+ ``{"file", "status", ["error"]}`` dict), for inline
817
+ per-file logging.
818
+
819
+ Returns:
820
+ A list of dicts with keys ``"file"`` (the name), ``"status"``
821
+ (``"deleted"`` or ``"failed"``), and ``"error"`` (only on
822
+ failure) — the same shape every batch op returns.
823
+ """
824
+ names = [str(n) for n in file_names]
825
+ # Arcana file names are flat (as listed by :meth:`list_files`). The
826
+ # batch executor below is Path-centric and targets ``Path(n).name``;
827
+ # a name containing ``/`` would silently collapse to its basename and
828
+ # delete the WRONG file. Refuse the whole batch up front — atomic, so
829
+ # nothing is deleted if any name is malformed (e.g. from a bad CSV).
830
+ bad = [n for n in names if "/" in n]
831
+ if bad:
832
+ raise ValueError(
833
+ f"ARCANA file names are flat (no '/'); refusing to delete "
834
+ f"{len(bad)} name(s) containing a path separator: {bad}"
835
+ )
836
+ # Reuse the shared batch executor (iteration, per-file error capture,
837
+ # progress bar, on_result, tally). With flat names ``Path(n).name ==
838
+ # n``, so the label/delete-target round-trips exactly.
839
+ return self._run_file_batch(
840
+ [Path(n) for n in names],
841
+ lambda fp: self.delete_file(name, fp.name),
842
+ default_status="deleted",
843
+ desc="Deleting",
844
+ verbose=verbose,
845
+ on_result=(
846
+ None
847
+ if on_result is None
848
+ else lambda fp, entry: on_result(fp.name, entry)
849
+ ),
850
+ )
851
+
745
852
  def download_file(self, name: str, file_name: str, output_path: str | Path) -> Path:
746
853
  """Download a file from an arcana to a local path.
747
854
 
saia_python/exceptions.py CHANGED
@@ -1,18 +1,43 @@
1
- """Custom exceptions and shared HTTP error handling for the SAIA Python wrapper."""
1
+ """Custom exceptions and shared HTTP error handling for the SAIA Python wrapper.
2
+
3
+ :func:`raise_for_status` is transport-agnostic: it inspects only ``status_code``,
4
+ ``headers``, ``json()`` and ``text`` — the attributes ``requests.Response`` and
5
+ ``httpx.Response`` share — so the sync and async paths raise the *same* typed
6
+ errors. Every error now carries ``status_code`` + ``response_body`` (so a gateway
7
+ can reframe the upstream response faithfully), and a 429 carries both the parsed
8
+ ``rate_limits`` and an informative message (which window, when it resets, how to
9
+ auto-retry).
10
+ """
2
11
 
3
12
  from __future__ import annotations
4
13
 
5
14
  import json as _json
6
- from typing import TYPE_CHECKING
7
-
8
- from .rate_limits import parse_rate_limits
15
+ from typing import Any
9
16
 
10
- if TYPE_CHECKING:
11
- import requests
17
+ from .rate_limits import format_rate_limit_error, parse_rate_limits
12
18
 
13
19
 
14
20
  class SAIAError(Exception):
15
- """Base exception for all SAIA API errors."""
21
+ """Base exception for all SAIA API errors.
22
+
23
+ Args:
24
+ message: Human-readable error message.
25
+ status_code: The upstream HTTP status, when the error came from a
26
+ response (``None`` for client-side errors).
27
+ response_body: The raw upstream body, preserved so a caller can surface
28
+ exactly what the server sent.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ message: object,
34
+ *,
35
+ status_code: int | None = None,
36
+ response_body: str | None = None,
37
+ ):
38
+ super().__init__(message)
39
+ self.status_code = status_code
40
+ self.response_body = response_body
16
41
 
17
42
 
18
43
  class AuthenticationError(SAIAError):
@@ -20,27 +45,43 @@ class AuthenticationError(SAIAError):
20
45
 
21
46
 
22
47
  class RateLimitError(SAIAError):
23
- """Raised on 429 responses — rate limit exceeded."""
48
+ """Raised on 429 responses — rate limit exceeded.
24
49
 
25
- def __init__(self, message, rate_limits=None):
26
- super().__init__(message)
50
+ Carries :attr:`rate_limits` (the parsed :class:`~saia_python.RateLimitInfo`)
51
+ and, when raised by :func:`raise_for_status`, an informative message built
52
+ by :func:`~saia_python.rate_limits.format_rate_limit_error`.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ message: object,
58
+ rate_limits: object = None,
59
+ *,
60
+ status_code: int | None = None,
61
+ response_body: str | None = None,
62
+ ):
63
+ super().__init__(message, status_code=status_code, response_body=response_body)
27
64
  self.rate_limits = rate_limits
28
65
 
29
66
 
30
67
  class APIError(SAIAError):
31
68
  """Raised on unexpected HTTP errors."""
32
69
 
33
- def __init__(self, message, status_code=None, response_body=None):
34
- super().__init__(message)
35
- self.status_code = status_code
36
- self.response_body = response_body
70
+ def __init__(
71
+ self,
72
+ message: object,
73
+ status_code: int | None = None,
74
+ response_body: str | None = None,
75
+ ):
76
+ super().__init__(message, status_code=status_code, response_body=response_body)
37
77
 
38
78
 
39
- def _extract_detail(resp: requests.Response) -> str:
79
+ def _extract_detail(resp: Any) -> str:
40
80
  """Try to extract a human-readable message from a JSON error body.
41
81
 
42
82
  The SAIA API typically returns ``{"detail": "..."}`` on errors.
43
- Falls back to the raw response text.
83
+ Falls back to the raw response text. Works with any response exposing
84
+ ``json()`` / ``text`` (``requests`` or ``httpx``).
44
85
  """
45
86
  try:
46
87
  body = resp.json()
@@ -51,18 +92,33 @@ def _extract_detail(resp: requests.Response) -> str:
51
92
  return resp.text
52
93
 
53
94
 
54
- def raise_for_status(resp: requests.Response) -> None:
95
+ def raise_for_status(resp: Any) -> None:
55
96
  """Raise a typed SAIA exception for HTTP error responses.
56
97
 
57
- Called by all service modules — this is the single implementation.
98
+ The single implementation shared by every service, sync and async. Accepts
99
+ a ``requests.Response`` (which has ``.ok``) or an ``httpx.Response`` (which
100
+ does not — success is decided from ``status_code`` instead). For a streamed
101
+ response the body must already be read (``await resp.aread()``) first.
58
102
  """
59
- if resp.ok:
103
+ ok = getattr(resp, "ok", None)
104
+ if ok is None:
105
+ ok = resp.status_code < 400
106
+ if ok:
60
107
  return
61
108
  detail = _extract_detail(resp)
109
+ body = resp.text
62
110
  if resp.status_code in (401, 403):
63
- raise AuthenticationError(detail)
111
+ raise AuthenticationError(
112
+ detail, status_code=resp.status_code, response_body=body
113
+ )
64
114
  if resp.status_code == 429:
65
- raise RateLimitError(detail, rate_limits=parse_rate_limits(resp.headers))
115
+ info = parse_rate_limits(resp.headers)
116
+ raise RateLimitError(
117
+ format_rate_limit_error(info, detail),
118
+ rate_limits=info,
119
+ status_code=resp.status_code,
120
+ response_body=body,
121
+ )
66
122
  # Any other non-2xx/3xx status. The early `return` above already handled
67
- # the resp.ok case, so reaching here always means an error response.
68
- raise APIError(detail, status_code=resp.status_code, response_body=resp.text)
123
+ # the success case, so reaching here always means an error response.
124
+ raise APIError(detail, status_code=resp.status_code, response_body=body)
@@ -68,7 +68,8 @@ def parse_rate_limits(headers) -> RateLimitInfo:
68
68
  """Parse rate-limit info from HTTP response headers.
69
69
 
70
70
  Args:
71
- headers: A dict-like object (e.g. ``requests.Response.headers``).
71
+ headers: A dict-like object (e.g. ``requests.Response.headers`` or
72
+ ``httpx.Response.headers``).
72
73
 
73
74
  Returns:
74
75
  RateLimitInfo with populated fields for each header found.
@@ -82,3 +83,37 @@ def parse_rate_limits(headers) -> RateLimitInfo:
82
83
  except (ValueError, TypeError):
83
84
  pass
84
85
  return RateLimitInfo(**kwargs)
86
+
87
+
88
+ def format_rate_limit_error(info: RateLimitInfo, detail: str | None = None) -> str:
89
+ """Build a human-readable message for a 429 (rate-limit) response.
90
+
91
+ Pure — parses nothing, does no I/O — so both the sync and async transports
92
+ raise the *same* informative :class:`~saia_python.RateLimitError` message
93
+ when retry is off or the budget is spent, instead of a bare server string.
94
+ Summarises the remaining per-window quota, the reset hint, and how to make
95
+ the call wait automatically.
96
+
97
+ Args:
98
+ info: The parsed rate-limit headers from the 429 response.
99
+ detail: The server's own error detail, appended when present.
100
+ """
101
+ parts = ["SAIA rate limit exceeded (HTTP 429)."]
102
+ windows = []
103
+ for window in ("minute", "hour", "day", "month"):
104
+ limit = getattr(info, f"limit_{window}")
105
+ remaining = getattr(info, f"remaining_{window}")
106
+ if limit is not None:
107
+ rem = remaining if remaining is not None else "?"
108
+ windows.append(f"{window} {rem}/{limit}")
109
+ if windows:
110
+ parts.append("Remaining — " + ", ".join(windows) + ".")
111
+ if info.reset_seconds is not None and info.reset_seconds > 0:
112
+ parts.append(f"The minute window resets in ~{info.reset_seconds}s.")
113
+ parts.append(
114
+ "Pass retry=True (or a RetryPolicy) to wait for the reset and retry "
115
+ "automatically; retry=False fails fast with this error."
116
+ )
117
+ if detail and detail.strip():
118
+ parts.append(f"Server detail: {detail.strip()}")
119
+ return " ".join(parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: saia-python
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Python wrapper for the GWDG SAIA platform REST API
5
5
  Author: Friedrich Schwarz
6
6
  License-Expression: AGPL-3.0-only
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Framework :: AsyncIO
25
26
  Classifier: Typing :: Typed
26
27
  Requires-Python: >=3.10
27
28
  Description-Content-Type: text/markdown
@@ -31,6 +32,8 @@ Requires-Dist: tqdm>=4.60
31
32
  Requires-Dist: tomlkit>=0.12
32
33
  Provides-Extra: openai
33
34
  Requires-Dist: openai>=1.0; extra == "openai"
35
+ Provides-Extra: async
36
+ Requires-Dist: httpx>=0.27; extra == "async"
34
37
  Provides-Extra: tokenizer
35
38
  Requires-Dist: transformers>=4.40; extra == "tokenizer"
36
39
  Requires-Dist: huggingface-hub>=0.20; extra == "tokenizer"
@@ -39,7 +42,7 @@ Requires-Dist: sentencepiece>=0.1.99; extra == "tokenizer"
39
42
  Provides-Extra: test
40
43
  Requires-Dist: pytest>=7.0; extra == "test"
41
44
  Requires-Dist: pytest-cov>=4.0; extra == "test"
42
- Requires-Dist: saia-python[openai]; extra == "test"
45
+ Requires-Dist: saia-python[async,openai]; extra == "test"
43
46
  Provides-Extra: docs
44
47
  Requires-Dist: sphinx>=7.0; extra == "docs"
45
48
  Requires-Dist: pydata-sphinx-theme>=0.15; extra == "docs"
@@ -113,6 +116,52 @@ list_model_ids()
113
116
  chat_completion(model="meta-llama-3.1-8b-instruct", messages=[...])
114
117
  ```
115
118
 
119
+ ### Async
120
+
121
+ For concurrent workloads (e.g. an ASGI service), install the `[async]` extra and
122
+ use `AsyncSAIAClient` — the `httpx.AsyncClient` twin of the data plane, carrying
123
+ the **same** `RetryPolicy` and rate-limit handling as the sync client (not the
124
+ `openai_async` shim, which bypasses them):
125
+
126
+ ```bash
127
+ pip install saia-python[async]
128
+ ```
129
+
130
+ ```python
131
+ import asyncio
132
+ from saia_python.aio import AsyncSAIAClient
133
+
134
+
135
+ async def main():
136
+ async with AsyncSAIAClient() as client:
137
+ # Non-streaming RAG chat
138
+ answer = await client.arcana.chat(
139
+ model="openai-gpt-oss-120b",
140
+ messages=[{"role": "user", "content": "Summarise the DLBCL first line."}],
141
+ arcana_id="owner/kb",
142
+ )
143
+ print(answer["choices"][0]["message"]["content"])
144
+
145
+ # Streaming plain chat — retry=False fails fast with an informative 429
146
+ stream = await client.chat.completions(
147
+ model="meta-llama-3.1-8b-instruct",
148
+ messages=[{"role": "user", "content": "Hello!"}],
149
+ stream=True,
150
+ retry=False,
151
+ )
152
+ async for chunk in stream:
153
+ ...
154
+
155
+
156
+ asyncio.run(main())
157
+ ```
158
+
159
+ Async covers the data plane (chat, ARCANA RAG chat, streaming) plus the
160
+ read-only control-plane calls (`models`, arcana `version`/`heartbeat`/`list`/
161
+ `get`, `health_check`). File upload/index/sync, voice, and document conversion
162
+ remain synchronous on `SAIAClient` — see
163
+ [ADR-0007](docs/adr/0007-native-async-transport.md).
164
+
116
165
  ## Supported Services
117
166
 
118
167
  | Service | Description | GWDG Docs |
@@ -1,23 +1,27 @@
1
- saia_python/__init__.py,sha256=yJ2bHCJ5s3NJWGaXRUCrkhg5SZ_8A7CcGKa3s0bSuCQ,7565
1
+ saia_python/__init__.py,sha256=h7QDujGai4lJ20etSMAxTeq5q5gtMIDAtOH2nqT2tuI,8640
2
+ saia_python/_async_http.py,sha256=kx_1n6984UCuu6tUU_uhcWrLdQk_EZd-L9HLzah0Iis,4687
3
+ saia_python/_async_streaming.py,sha256=hY0u8z_9gbKtBcd2gp1cnWzIWJDCTN1z68SgP186pxg,6448
2
4
  saia_python/_http.py,sha256=OW7-3TeTl9efBcdsqL2tmAqeTv_cnUs0j-rfeGCY3Cs,9720
5
+ saia_python/_payloads.py,sha256=YK-oaaswx2_8Ao7Qx5duzowCWCEHVDCVsGQirDTMuFc,3349
3
6
  saia_python/_streaming.py,sha256=camkWgoeqBky3b-v4xTC8i4gmADpwpBqtpCVcPCGMvA,3107
4
7
  saia_python/_util.py,sha256=1QrgVxAEK6CwJTNK36qLv9GKTs3ySqg4FJKjx_ZEolM,867
5
- saia_python/arcana.py,sha256=G8_DtNgS6fWXyOHz4h950isuJHXT0lhN4KPBdDuiabc,49243
8
+ saia_python/aio.py,sha256=yu7K1q_UiBmaW-rTfEkP57RVAlx8PwWq37qvtm6gaDs,16858
9
+ saia_python/arcana.py,sha256=gmh4IuC-k5ia5KVQScXQvtD0FhrZj4v8E78C6TRUn5E,54435
6
10
  saia_python/arcana_references.py,sha256=wVhbsQdqjtKaiEJu0Lpp26Nbqne7rXgHNE6qKH6cSfA,6818
7
11
  saia_python/auth.py,sha256=iKKT7hI3oPtz_rq-wLj-S-_XT7RIGIFgeNrY5nCNNB4,16432
8
12
  saia_python/chat.py,sha256=VVqrPbR5UU7nsg7YNCUT5uv5IMRRmwkKvqz72O0W3ZQ,2673
9
13
  saia_python/client.py,sha256=P4c41LLfkeutUh4anfcbaEg_nNc3AN_KyHA0hf-YF0E,10520
10
14
  saia_python/documents.py,sha256=O8nnqxfXs7Yzq7fYhSm7w1dGTaxBAvoKcvtV9yToDJ8,8680
11
- saia_python/exceptions.py,sha256=_eY-JSfubWvYkKV7GVbFrdHip7MfwdHwL6In-MbP9X8,2074
15
+ saia_python/exceptions.py,sha256=qb4wXkF2L7qq-qJS3dTTPigTC4F6P-09t0oyGpTpjmc,4252
12
16
  saia_python/models.py,sha256=ZGm94FTHY2pcKzU-A3g5g_gGLAYnx4Clq7wCB6NZmqc,5557
13
17
  saia_python/openai_compat.py,sha256=RkZp-uKLpOk-UlTMpQpF_E-1_LtiAbUC4IqLWBOjM5k,2349
14
18
  saia_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- saia_python/rate_limits.py,sha256=ITIF8CQkHpPVT1d_zEiFNXSt6uFQYagp9QE3C1i_m6Q,2841
19
+ saia_python/rate_limits.py,sha256=5UTn9_sOG6dURXwMqs7j3VHHPHg4pGBEUI18ffSDLVo,4435
16
20
  saia_python/responses.py,sha256=alemqTlwpeVS3u7W_DG0HG-KCW19ficjpP5Cs6GvqR8,2206
17
21
  saia_python/tokenizer.py,sha256=e8GZFi0kfwgGS-9-GL8sNBjGh5MihX57FGiOz5ETbP8,59694
18
22
  saia_python/voice.py,sha256=CFZ2TQ8y-eCZ0H1z7P_JKWEORkjRYu1LZriUl4hzPws,7004
19
- saia_python-0.7.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
20
- saia_python-0.7.0.dist-info/METADATA,sha256=ynRkIJAQ1LQbdBe2P-iWda5W6GFKz4Wy48826XG7yUQ,8791
21
- saia_python-0.7.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
22
- saia_python-0.7.0.dist-info/top_level.txt,sha256=xQzdM-ir7Don4KJtPWWUkyLX9Ry2rai2OrnaWc7GMKw,12
23
- saia_python-0.7.0.dist-info/RECORD,,
23
+ saia_python-0.9.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
24
+ saia_python-0.9.0.dist-info/METADATA,sha256=W9d3xWl_icWKPX9LXOjALdz2AGVfZluTUSZqsV-u3uU,10383
25
+ saia_python-0.9.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
26
+ saia_python-0.9.0.dist-info/top_level.txt,sha256=xQzdM-ir7Don4KJtPWWUkyLX9Ry2rai2OrnaWc7GMKw,12
27
+ saia_python-0.9.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5