saia-python 0.4.1__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 +253 -0
- saia_python/_http.py +71 -0
- saia_python/_streaming.py +88 -0
- saia_python/_util.py +29 -0
- saia_python/arcana.py +1061 -0
- saia_python/arcana_references.py +182 -0
- saia_python/auth.py +515 -0
- saia_python/chat.py +72 -0
- saia_python/client.py +239 -0
- saia_python/documents.py +145 -0
- saia_python/exceptions.py +68 -0
- saia_python/models.py +146 -0
- saia_python/openai_compat.py +70 -0
- saia_python/py.typed +0 -0
- saia_python/rate_limits.py +84 -0
- saia_python/responses.py +70 -0
- saia_python/voice.py +175 -0
- saia_python-0.4.1.dist-info/METADATA +190 -0
- saia_python-0.4.1.dist-info/RECORD +22 -0
- saia_python-0.4.1.dist-info/WHEEL +5 -0
- saia_python-0.4.1.dist-info/licenses/LICENSE +661 -0
- saia_python-0.4.1.dist-info/top_level.txt +1 -0
saia_python/__init__.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""SAIA Python — a wrapper for the GWDG SAIA platform REST API.
|
|
2
|
+
|
|
3
|
+
Provides both an object-oriented and a functional interface::
|
|
4
|
+
|
|
5
|
+
# OOP
|
|
6
|
+
from saia_python import SAIAClient
|
|
7
|
+
client = SAIAClient(api_key="...")
|
|
8
|
+
client.models.list_ids()
|
|
9
|
+
|
|
10
|
+
# Functional
|
|
11
|
+
from saia_python import list_model_ids, chat_completion
|
|
12
|
+
list_model_ids()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import concurrent.futures
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
from ._streaming import SSEStream
|
|
21
|
+
from .arcana_references import (
|
|
22
|
+
ArcanaReference,
|
|
23
|
+
ParsedReferences,
|
|
24
|
+
is_arcana_event,
|
|
25
|
+
parse_arcana_references,
|
|
26
|
+
parse_reference_entries,
|
|
27
|
+
)
|
|
28
|
+
from .auth import (
|
|
29
|
+
DEFAULT_BASE_URL,
|
|
30
|
+
add_arcana_to_config,
|
|
31
|
+
load_api_key,
|
|
32
|
+
load_arcana_ids,
|
|
33
|
+
load_config,
|
|
34
|
+
load_username,
|
|
35
|
+
remove_arcana_from_config,
|
|
36
|
+
resolve_base_url,
|
|
37
|
+
)
|
|
38
|
+
from .client import SAIAClient
|
|
39
|
+
from .documents import ConversionResult
|
|
40
|
+
from .exceptions import APIError, AuthenticationError, RateLimitError, SAIAError
|
|
41
|
+
from .openai_compat import create_openai_client
|
|
42
|
+
from .rate_limits import RateLimitInfo, parse_rate_limits
|
|
43
|
+
from .responses import text_of
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
__version__ = version("saia-python")
|
|
47
|
+
except PackageNotFoundError:
|
|
48
|
+
__version__ = "0.0.0-dev"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
# Meta
|
|
52
|
+
"__version__",
|
|
53
|
+
# Client
|
|
54
|
+
"SAIAClient",
|
|
55
|
+
"resolve_base_url",
|
|
56
|
+
"DEFAULT_BASE_URL",
|
|
57
|
+
"create_openai_client",
|
|
58
|
+
# Auth
|
|
59
|
+
"load_api_key",
|
|
60
|
+
"load_arcana_ids",
|
|
61
|
+
"load_config",
|
|
62
|
+
"load_username",
|
|
63
|
+
"add_arcana_to_config",
|
|
64
|
+
"remove_arcana_from_config",
|
|
65
|
+
# Exceptions
|
|
66
|
+
"SAIAError",
|
|
67
|
+
"AuthenticationError",
|
|
68
|
+
"RateLimitError",
|
|
69
|
+
"APIError",
|
|
70
|
+
# Rate limits
|
|
71
|
+
"RateLimitInfo",
|
|
72
|
+
"parse_rate_limits",
|
|
73
|
+
# Response helpers
|
|
74
|
+
"text_of",
|
|
75
|
+
"SSEStream",
|
|
76
|
+
# ARCANA reference parsing
|
|
77
|
+
"ArcanaReference",
|
|
78
|
+
"ParsedReferences",
|
|
79
|
+
"parse_arcana_references",
|
|
80
|
+
"parse_reference_entries",
|
|
81
|
+
"is_arcana_event",
|
|
82
|
+
# Functional API
|
|
83
|
+
"list_models",
|
|
84
|
+
"list_model_ids",
|
|
85
|
+
"chat_completion",
|
|
86
|
+
"transcribe",
|
|
87
|
+
"translate",
|
|
88
|
+
"list_arcanas",
|
|
89
|
+
"get_arcana",
|
|
90
|
+
"upload_to_arcana",
|
|
91
|
+
"arcana_chat",
|
|
92
|
+
"get_rate_limits",
|
|
93
|
+
"convert_document",
|
|
94
|
+
"ConversionResult",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _make_client(api_key: str | None = None, base_url: str | None = None) -> SAIAClient:
|
|
99
|
+
kwargs: dict = {}
|
|
100
|
+
if api_key is not None:
|
|
101
|
+
kwargs["api_key"] = api_key
|
|
102
|
+
if base_url is not None:
|
|
103
|
+
kwargs["base_url"] = base_url
|
|
104
|
+
return SAIAClient(**kwargs)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- Models ---
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_models(
|
|
111
|
+
*, api_key: str | None = None, base_url: str | None = None
|
|
112
|
+
) -> list[dict]:
|
|
113
|
+
"""List all available models (functional API)."""
|
|
114
|
+
return _make_client(api_key, base_url).models.list()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_model_ids(
|
|
118
|
+
*, api_key: str | None = None, base_url: str | None = None
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
"""List model ID strings (functional API)."""
|
|
121
|
+
return _make_client(api_key, base_url).models.list_ids()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- Chat ---
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def chat_completion(
|
|
128
|
+
model: str,
|
|
129
|
+
messages: list[dict],
|
|
130
|
+
*,
|
|
131
|
+
api_key: str | None = None,
|
|
132
|
+
base_url: str | None = None,
|
|
133
|
+
**kwargs,
|
|
134
|
+
) -> dict | SSEStream:
|
|
135
|
+
"""Send a chat completion request (functional API).
|
|
136
|
+
|
|
137
|
+
See :meth:`ChatService.completions` for full parameter docs. With
|
|
138
|
+
``stream=True`` this returns an iterable ``SSEStream`` instead of a dict.
|
|
139
|
+
"""
|
|
140
|
+
return _make_client(api_key, base_url).chat.completions(
|
|
141
|
+
model=model, messages=messages, **kwargs
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- Voice ---
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def transcribe(
|
|
149
|
+
file_path: str,
|
|
150
|
+
*,
|
|
151
|
+
api_key: str | None = None,
|
|
152
|
+
base_url: str | None = None,
|
|
153
|
+
**kwargs,
|
|
154
|
+
) -> str | concurrent.futures.Future[str]:
|
|
155
|
+
"""Transcribe an audio file (functional API).
|
|
156
|
+
|
|
157
|
+
See :meth:`VoiceService.transcribe` for full parameter docs. Passing
|
|
158
|
+
``wait=False`` returns a :class:`concurrent.futures.Future` instead of
|
|
159
|
+
the transcription string.
|
|
160
|
+
"""
|
|
161
|
+
return _make_client(api_key, base_url).voice.transcribe(file_path, **kwargs)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def translate(
|
|
165
|
+
file_path: str,
|
|
166
|
+
*,
|
|
167
|
+
api_key: str | None = None,
|
|
168
|
+
base_url: str | None = None,
|
|
169
|
+
**kwargs,
|
|
170
|
+
) -> str | concurrent.futures.Future[str]:
|
|
171
|
+
"""Translate an audio file to English (functional API).
|
|
172
|
+
|
|
173
|
+
See :meth:`VoiceService.translate` for full parameter docs. Passing
|
|
174
|
+
``wait=False`` returns a :class:`concurrent.futures.Future` instead of
|
|
175
|
+
the translation string.
|
|
176
|
+
"""
|
|
177
|
+
return _make_client(api_key, base_url).voice.translate(file_path, **kwargs)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- ARCANA ---
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def list_arcanas(
|
|
184
|
+
*, api_key: str | None = None, base_url: str | None = None
|
|
185
|
+
) -> list[dict]:
|
|
186
|
+
"""List all arcanas (functional API)."""
|
|
187
|
+
return _make_client(api_key, base_url).arcana.list()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_arcana(
|
|
191
|
+
name: str, *, api_key: str | None = None, base_url: str | None = None
|
|
192
|
+
) -> dict:
|
|
193
|
+
"""Get a specific arcana by name (functional API)."""
|
|
194
|
+
return _make_client(api_key, base_url).arcana.get(name)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def upload_to_arcana(
|
|
198
|
+
name: str,
|
|
199
|
+
file_path: str,
|
|
200
|
+
*,
|
|
201
|
+
api_key: str | None = None,
|
|
202
|
+
base_url: str | None = None,
|
|
203
|
+
) -> dict | None:
|
|
204
|
+
"""Upload a file to an arcana (functional API)."""
|
|
205
|
+
return _make_client(api_key, base_url).arcana.upload(name, file_path)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def arcana_chat(
|
|
209
|
+
model: str,
|
|
210
|
+
messages: list[dict],
|
|
211
|
+
arcana_id: str,
|
|
212
|
+
*,
|
|
213
|
+
api_key: str | None = None,
|
|
214
|
+
base_url: str | None = None,
|
|
215
|
+
**kwargs,
|
|
216
|
+
) -> dict | SSEStream:
|
|
217
|
+
"""Chat with RAG context from an arcana (functional API).
|
|
218
|
+
|
|
219
|
+
With ``stream=True`` this returns an iterable ``SSEStream`` instead of a dict.
|
|
220
|
+
"""
|
|
221
|
+
return _make_client(api_key, base_url).arcana.chat(
|
|
222
|
+
model=model, messages=messages, arcana_id=arcana_id, **kwargs
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- Rate Limits ---
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_rate_limits(
|
|
230
|
+
*, api_key: str | None = None, base_url: str | None = None
|
|
231
|
+
) -> RateLimitInfo:
|
|
232
|
+
"""Fetch current rate-limit status (functional API)."""
|
|
233
|
+
return _make_client(api_key, base_url).get_rate_limits()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --- Documents ---
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def convert_document(
|
|
240
|
+
file_path: str,
|
|
241
|
+
*,
|
|
242
|
+
response_type: str = "markdown",
|
|
243
|
+
api_key: str | None = None,
|
|
244
|
+
base_url: str | None = None,
|
|
245
|
+
**kwargs,
|
|
246
|
+
) -> ConversionResult:
|
|
247
|
+
"""Convert a document using the Docling service (functional API).
|
|
248
|
+
|
|
249
|
+
See :meth:`DocumentService.convert` for full parameter docs.
|
|
250
|
+
"""
|
|
251
|
+
return _make_client(api_key, base_url).documents.convert(
|
|
252
|
+
file_path, response_type=response_type, **kwargs
|
|
253
|
+
)
|
saia_python/_http.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared HTTP plumbing used by more than one service.
|
|
2
|
+
|
|
3
|
+
Kept in one place so the chat-completion request shape and the
|
|
4
|
+
background-thread ``Session`` helper each have a single implementation,
|
|
5
|
+
rather than being copied across :mod:`saia_python.chat`,
|
|
6
|
+
:mod:`saia_python.arcana`, and :mod:`saia_python.voice`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from ._streaming import SSEStream
|
|
14
|
+
from .exceptions import raise_for_status
|
|
15
|
+
from .rate_limits import parse_rate_limits
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_session_like(template: requests.Session) -> requests.Session:
|
|
19
|
+
"""Return a fresh :class:`requests.Session` mirroring ``template``'s headers.
|
|
20
|
+
|
|
21
|
+
Background-thread work must not reuse the caller's ``Session`` —
|
|
22
|
+
``requests.Session`` is not guaranteed thread-safe, and sharing its
|
|
23
|
+
connection pool across threads can corrupt in-flight requests. Both the
|
|
24
|
+
non-blocking Voice path and the fire-and-forget ARCANA index trigger spin
|
|
25
|
+
up their own ``Session`` through this helper so they never race the
|
|
26
|
+
client's.
|
|
27
|
+
"""
|
|
28
|
+
session = requests.Session()
|
|
29
|
+
session.headers.update(template.headers)
|
|
30
|
+
return session
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def post_chat_completion(
|
|
34
|
+
session: requests.Session,
|
|
35
|
+
url: str,
|
|
36
|
+
body: dict,
|
|
37
|
+
*,
|
|
38
|
+
headers: dict | None = None,
|
|
39
|
+
stream: bool = False,
|
|
40
|
+
) -> dict | SSEStream:
|
|
41
|
+
"""POST a chat-completion request and normalise the response.
|
|
42
|
+
|
|
43
|
+
Shared by :meth:`ChatService.completions` and :meth:`ArcanaService.chat`:
|
|
44
|
+
both hit the same ``/chat/completions`` endpoint with identical
|
|
45
|
+
stream/non-stream handling and rate-limit surfacing — only the request
|
|
46
|
+
``body`` fields and auth ``headers`` differ, so those stay with the caller.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
session: The authenticated :class:`requests.Session`.
|
|
50
|
+
url: The fully-qualified ``/chat/completions`` URL.
|
|
51
|
+
body: The request JSON body (already assembled by the caller).
|
|
52
|
+
headers: Per-request headers. ``None`` uses the session defaults
|
|
53
|
+
(the Bearer auth + ``Accept: application/json``).
|
|
54
|
+
stream: When ``True``, request SSE and return an :class:`SSEStream`.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
When ``stream=False``: the response dict with an extra
|
|
58
|
+
``"_rate_limits"`` key (a JSON-serializable dict). When ``stream=True``:
|
|
59
|
+
an :class:`SSEStream` whose ``rate_limits`` attribute holds the same dict.
|
|
60
|
+
"""
|
|
61
|
+
if stream:
|
|
62
|
+
stream_body = {**body, "stream": True}
|
|
63
|
+
stream_headers = {**(headers or {}), "Accept": "text/event-stream"}
|
|
64
|
+
resp = session.post(url, json=stream_body, headers=stream_headers, stream=True)
|
|
65
|
+
return SSEStream(resp)
|
|
66
|
+
|
|
67
|
+
resp = session.post(url, json=body, headers=headers)
|
|
68
|
+
raise_for_status(resp)
|
|
69
|
+
result = resp.json()
|
|
70
|
+
result["_rate_limits"] = parse_rate_limits(resp.headers).to_dict()
|
|
71
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared SSE (Server-Sent Events) streaming iterator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .exceptions import raise_for_status
|
|
10
|
+
from .rate_limits import parse_rate_limits
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def iter_sse(response: requests.Response) -> Generator[dict, None, None]:
|
|
17
|
+
"""Yield parsed JSON chunks from an SSE stream.
|
|
18
|
+
|
|
19
|
+
Handles the ``data: {...}`` / ``data: [DONE]`` protocol used by
|
|
20
|
+
the OpenAI-compatible SAIA API.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
response: A streaming :class:`requests.Response` (``stream=True``).
|
|
24
|
+
|
|
25
|
+
Yields:
|
|
26
|
+
Parsed JSON dicts for each ``data:`` line.
|
|
27
|
+
"""
|
|
28
|
+
raise_for_status(response)
|
|
29
|
+
try:
|
|
30
|
+
for line in response.iter_lines(decode_unicode=True):
|
|
31
|
+
if not line or not line.startswith("data:"):
|
|
32
|
+
continue
|
|
33
|
+
payload = line[len("data:") :].strip()
|
|
34
|
+
if payload == "[DONE]":
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
yield json.loads(payload)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
continue
|
|
40
|
+
finally:
|
|
41
|
+
# Release the underlying connection back to the pool whether the
|
|
42
|
+
# stream finished, hit [DONE], errored, or the consumer broke early
|
|
43
|
+
# (GeneratorExit). Without this, an abandoned stream leaks the socket
|
|
44
|
+
# until garbage collection.
|
|
45
|
+
response.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SSEStream:
|
|
49
|
+
"""Iterable over SSE chunks that also exposes parsed rate-limit info.
|
|
50
|
+
|
|
51
|
+
Wraps a streaming :class:`requests.Response` so callers can both iterate
|
|
52
|
+
the decoded chunks (``for chunk in stream``) and read the rate-limit
|
|
53
|
+
headers via :attr:`rate_limits` — available immediately, since headers
|
|
54
|
+
arrive before the streamed body. Mirrors the ``_rate_limits`` key that
|
|
55
|
+
non-streaming responses carry, giving both paths rate-limit parity.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
rate_limits: A JSON-serializable dict of this response's rate-limit
|
|
59
|
+
headers (the same shape as :class:`~saia_python.RateLimitInfo`
|
|
60
|
+
via ``to_dict()``).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, response: requests.Response):
|
|
64
|
+
self._response = response
|
|
65
|
+
self.rate_limits: dict = parse_rate_limits(response.headers).to_dict()
|
|
66
|
+
self._chunks = iter_sse(response)
|
|
67
|
+
|
|
68
|
+
def __iter__(self) -> SSEStream:
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __next__(self) -> dict:
|
|
72
|
+
return next(self._chunks)
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Close the stream, releasing the underlying connection.
|
|
76
|
+
|
|
77
|
+
Closes both the chunk generator (running its cleanup) and the
|
|
78
|
+
response directly, so the connection is released even if iteration
|
|
79
|
+
never started.
|
|
80
|
+
"""
|
|
81
|
+
self._chunks.close()
|
|
82
|
+
self._response.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> SSEStream:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc) -> None:
|
|
88
|
+
self.close()
|
saia_python/_util.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Small internal utilities shared across services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def progress_iter(
|
|
9
|
+
items: Iterable,
|
|
10
|
+
*,
|
|
11
|
+
desc: str,
|
|
12
|
+
unit: str = "file",
|
|
13
|
+
enabled: bool = True,
|
|
14
|
+
) -> Iterator:
|
|
15
|
+
"""Wrap ``items`` in a ``tqdm`` progress bar when available and ``enabled``.
|
|
16
|
+
|
|
17
|
+
``tqdm`` is an optional dependency, so this degrades to a plain iterator
|
|
18
|
+
when it is not installed (or when ``enabled`` is ``False`` — e.g. a caller
|
|
19
|
+
that prints its own per-item lines instead of a bar). Centralises the
|
|
20
|
+
``try: from tqdm.auto import tqdm`` dance that several services otherwise
|
|
21
|
+
repeat.
|
|
22
|
+
"""
|
|
23
|
+
if not enabled:
|
|
24
|
+
return iter(items)
|
|
25
|
+
try:
|
|
26
|
+
from tqdm.auto import tqdm
|
|
27
|
+
except ImportError:
|
|
28
|
+
return iter(items)
|
|
29
|
+
return tqdm(items, desc=desc, unit=unit)
|