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 +30 -1
- saia_python/_async_http.py +127 -0
- saia_python/_async_streaming.py +157 -0
- saia_python/_payloads.py +81 -0
- saia_python/aio.py +464 -0
- saia_python/arcana.py +107 -0
- saia_python/exceptions.py +79 -23
- saia_python/rate_limits.py +36 -1
- {saia_python-0.7.0.dist-info → saia_python-0.9.0.dist-info}/METADATA +51 -2
- {saia_python-0.7.0.dist-info → saia_python-0.9.0.dist-info}/RECORD +13 -9
- {saia_python-0.7.0.dist-info → saia_python-0.9.0.dist-info}/WHEEL +1 -1
- {saia_python-0.7.0.dist-info → saia_python-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {saia_python-0.7.0.dist-info → saia_python-0.9.0.dist-info}/top_level.txt +0 -0
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()
|
saia_python/_payloads.py
ADDED
|
@@ -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
|
|
7
|
-
|
|
8
|
-
from .rate_limits import parse_rate_limits
|
|
15
|
+
from typing import Any
|
|
9
16
|
|
|
10
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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__(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
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:
|
|
95
|
+
def raise_for_status(resp: Any) -> None:
|
|
55
96
|
"""Raise a typed SAIA exception for HTTP error responses.
|
|
56
97
|
|
|
57
|
-
|
|
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
|
-
|
|
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(
|
|
111
|
+
raise AuthenticationError(
|
|
112
|
+
detail, status_code=resp.status_code, response_body=body
|
|
113
|
+
)
|
|
64
114
|
if resp.status_code == 429:
|
|
65
|
-
|
|
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
|
|
68
|
-
raise APIError(detail, status_code=resp.status_code, response_body=
|
|
123
|
+
# the success case, so reaching here always means an error response.
|
|
124
|
+
raise APIError(detail, status_code=resp.status_code, response_body=body)
|
saia_python/rate_limits.py
CHANGED
|
@@ -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.
|
|
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=
|
|
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/
|
|
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=
|
|
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=
|
|
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.
|
|
20
|
-
saia_python-0.
|
|
21
|
-
saia_python-0.
|
|
22
|
-
saia_python-0.
|
|
23
|
-
saia_python-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|