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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""OpenAI SDK compatibility layer for the SAIA platform.
|
|
2
|
+
|
|
3
|
+
Provides a factory function that creates an ``openai.OpenAI`` (or
|
|
4
|
+
``openai.AsyncOpenAI``) client pre-configured with SAIA credentials
|
|
5
|
+
and base URL. This enables direct use of the OpenAI Python SDK and
|
|
6
|
+
tools built on it (RAGAS, LangChain, instructor, etc.) against the
|
|
7
|
+
SAIA API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_openai_client(
|
|
14
|
+
*,
|
|
15
|
+
api_key: str | None = None,
|
|
16
|
+
base_url: str | None = None,
|
|
17
|
+
key_file: str | None = None,
|
|
18
|
+
async_client: bool = False,
|
|
19
|
+
):
|
|
20
|
+
"""Create an OpenAI client configured for the SAIA platform.
|
|
21
|
+
|
|
22
|
+
Reuses the same credential and base URL resolution as
|
|
23
|
+
:class:`~saia_python.SAIAClient`: environment variables, ``.env``,
|
|
24
|
+
``.saia_api``, and ``config.toml`` are checked automatically when
|
|
25
|
+
parameters are omitted.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
api_key: Explicit API key. If omitted, resolved via
|
|
29
|
+
:func:`~saia_python.load_api_key`.
|
|
30
|
+
base_url: Explicit base URL. If omitted, resolved via
|
|
31
|
+
:func:`~saia_python.resolve_base_url`.
|
|
32
|
+
key_file: Path to a ``.saia_api`` or ``.env`` file. Ignored
|
|
33
|
+
when ``api_key`` is provided.
|
|
34
|
+
async_client: If ``True``, return an ``openai.AsyncOpenAI``
|
|
35
|
+
instance instead of ``openai.OpenAI``.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
An ``openai.OpenAI`` or ``openai.AsyncOpenAI`` instance.
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
from saia_python import create_openai_client
|
|
43
|
+
|
|
44
|
+
client = create_openai_client()
|
|
45
|
+
response = client.chat.completions.create(
|
|
46
|
+
model="llama-3.3-70b-instruct",
|
|
47
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Embeddings
|
|
51
|
+
embedding = client.embeddings.create(
|
|
52
|
+
model="e5-mistral-7b-instruct",
|
|
53
|
+
input="Text to embed",
|
|
54
|
+
)
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import openai
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
raise ImportError(
|
|
60
|
+
"The OpenAI compatibility layer requires the optional 'openai' "
|
|
61
|
+
"dependency. Install it with:\n"
|
|
62
|
+
" pip install saia-python[openai]"
|
|
63
|
+
) from exc
|
|
64
|
+
|
|
65
|
+
from .auth import resolve_credentials
|
|
66
|
+
|
|
67
|
+
resolved_key, resolved_url = resolve_credentials(api_key, base_url, key_file)
|
|
68
|
+
|
|
69
|
+
cls = openai.AsyncOpenAI if async_client else openai.OpenAI
|
|
70
|
+
return cls(api_key=resolved_key, base_url=resolved_url)
|
saia_python/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Rate limit header parsing for SAIA API responses."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class RateLimitInfo:
|
|
8
|
+
"""Parsed rate-limit information from SAIA API response headers."""
|
|
9
|
+
|
|
10
|
+
limit_minute: int | None = None
|
|
11
|
+
limit_hour: int | None = None
|
|
12
|
+
limit_day: int | None = None
|
|
13
|
+
limit_month: int | None = None
|
|
14
|
+
remaining_minute: int | None = None
|
|
15
|
+
remaining_hour: int | None = None
|
|
16
|
+
remaining_day: int | None = None
|
|
17
|
+
remaining_month: int | None = None
|
|
18
|
+
reset_seconds: int | None = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
"""Return a plain, JSON-serializable dict of all rate-limit fields."""
|
|
22
|
+
return asdict(self)
|
|
23
|
+
|
|
24
|
+
def __str__(self):
|
|
25
|
+
rows = []
|
|
26
|
+
for window in ("minute", "hour", "day", "month"):
|
|
27
|
+
limit = getattr(self, f"limit_{window}")
|
|
28
|
+
remaining = getattr(self, f"remaining_{window}")
|
|
29
|
+
if limit is not None:
|
|
30
|
+
rem_str = str(remaining) if remaining is not None else "?"
|
|
31
|
+
used = str(limit - remaining) if remaining is not None else "?"
|
|
32
|
+
rows.append((window, rem_str, limit, used))
|
|
33
|
+
|
|
34
|
+
if not rows:
|
|
35
|
+
return "SAIA Rate Limits: (no data)"
|
|
36
|
+
|
|
37
|
+
w_rem = max(len(str(r[1])) for r in rows)
|
|
38
|
+
w_lim = max(len(str(r[2])) for r in rows)
|
|
39
|
+
w_used = max(len(str(r[3])) for r in rows)
|
|
40
|
+
|
|
41
|
+
lines = ["SAIA Rate Limits:"]
|
|
42
|
+
for window, remaining, limit, used in rows:
|
|
43
|
+
lines.append(
|
|
44
|
+
f" {window:<6} "
|
|
45
|
+
f"{remaining:>{w_rem}} / {limit:>{w_lim}} "
|
|
46
|
+
f"remaining "
|
|
47
|
+
f"({used:>{w_used}} used)"
|
|
48
|
+
)
|
|
49
|
+
if self.reset_seconds is not None:
|
|
50
|
+
lines.append(f" Resets in {self.reset_seconds}s")
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_HEADER_MAP = {
|
|
55
|
+
"x-ratelimit-limit-minute": "limit_minute",
|
|
56
|
+
"x-ratelimit-limit-hour": "limit_hour",
|
|
57
|
+
"x-ratelimit-limit-day": "limit_day",
|
|
58
|
+
"x-ratelimit-limit-month": "limit_month",
|
|
59
|
+
"x-ratelimit-remaining-minute": "remaining_minute",
|
|
60
|
+
"x-ratelimit-remaining-hour": "remaining_hour",
|
|
61
|
+
"x-ratelimit-remaining-day": "remaining_day",
|
|
62
|
+
"x-ratelimit-remaining-month": "remaining_month",
|
|
63
|
+
"ratelimit-reset": "reset_seconds",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_rate_limits(headers) -> RateLimitInfo:
|
|
68
|
+
"""Parse rate-limit info from HTTP response headers.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
headers: A dict-like object (e.g. ``requests.Response.headers``).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
RateLimitInfo with populated fields for each header found.
|
|
75
|
+
"""
|
|
76
|
+
kwargs = {}
|
|
77
|
+
for header_name, field_name in _HEADER_MAP.items():
|
|
78
|
+
value = headers.get(header_name)
|
|
79
|
+
if value is not None:
|
|
80
|
+
try:
|
|
81
|
+
kwargs[field_name] = int(value)
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
pass
|
|
84
|
+
return RateLimitInfo(**kwargs)
|
saia_python/responses.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Helpers for extracting fields from OpenAI-style API responses.
|
|
2
|
+
|
|
3
|
+
The SAIA Chat AI and ARCANA RAG endpoints both return the canonical
|
|
4
|
+
OpenAI ``ChatCompletion`` envelope::
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"choices": [
|
|
8
|
+
{"message": {"role": "assistant", "content": "<text>"}, ...}
|
|
9
|
+
],
|
|
10
|
+
...
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Every caller eventually writes the same lookup chain. The helpers in
|
|
14
|
+
this module make that lookup safe and uniform — empty ``choices`` lists
|
|
15
|
+
and missing ``content`` fields return ``""`` (with a logged warning) so
|
|
16
|
+
downstream string-handling code doesn't have to special-case them.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def text_of(response: dict) -> str:
|
|
27
|
+
"""Extract the assistant message content from a chat / RAG response.
|
|
28
|
+
|
|
29
|
+
Reaches into ``response["choices"][0]["message"]["content"]`` with
|
|
30
|
+
full nil-safety: an empty ``choices`` list, a missing ``message``
|
|
31
|
+
key, or a ``None`` ``content`` field all collapse to ``""`` rather
|
|
32
|
+
than raising. Both empty-response cases log a warning at this
|
|
33
|
+
module's logger so silent regressions surface in logs.
|
|
34
|
+
|
|
35
|
+
Works for the response shape returned by:
|
|
36
|
+
|
|
37
|
+
- :meth:`saia_python.ChatService.completions`
|
|
38
|
+
- :meth:`saia_python.ArcanaService.chat`
|
|
39
|
+
- ``client.openai.chat.completions.create(...).model_dump()``
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
response: An OpenAI-style ChatCompletion response dict.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The first choice's assistant content string, or ``""`` if the
|
|
46
|
+
response carries no usable content.
|
|
47
|
+
|
|
48
|
+
Example::
|
|
49
|
+
|
|
50
|
+
resp = client.arcana.chat(
|
|
51
|
+
model="...", messages=[...], arcana_id="...",
|
|
52
|
+
)
|
|
53
|
+
answer = saia_python.text_of(resp)
|
|
54
|
+
"""
|
|
55
|
+
choices = response.get("choices") or []
|
|
56
|
+
if not choices:
|
|
57
|
+
log.warning(
|
|
58
|
+
"text_of: response has no choices; keys=%s",
|
|
59
|
+
list(response.keys()),
|
|
60
|
+
)
|
|
61
|
+
return ""
|
|
62
|
+
message = choices[0].get("message") or {}
|
|
63
|
+
content = message.get("content")
|
|
64
|
+
if not content:
|
|
65
|
+
log.warning(
|
|
66
|
+
"text_of: first choice has empty content; finish_reason=%r",
|
|
67
|
+
choices[0].get("finish_reason"),
|
|
68
|
+
)
|
|
69
|
+
return ""
|
|
70
|
+
return str(content)
|
saia_python/voice.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Voice AI service — transcription and translation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ._http import new_session_like
|
|
11
|
+
from .exceptions import raise_for_status
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VoiceService:
|
|
18
|
+
"""Access the ``/audio/transcriptions`` and ``/audio/translations`` endpoints.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
session: A :class:`requests.Session` with auth headers configured.
|
|
22
|
+
base_url: The SAIA API base URL.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, session: requests.Session, base_url: str):
|
|
26
|
+
self._session = session
|
|
27
|
+
self._base_url = base_url
|
|
28
|
+
|
|
29
|
+
def transcribe(
|
|
30
|
+
self,
|
|
31
|
+
file_path: str | Path,
|
|
32
|
+
*,
|
|
33
|
+
model: str = "whisper-large-v2",
|
|
34
|
+
response_format: str = "text",
|
|
35
|
+
language: str | None = None,
|
|
36
|
+
wait: bool = True,
|
|
37
|
+
) -> str | concurrent.futures.Future[str]:
|
|
38
|
+
"""Transcribe an audio file to text.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
file_path: Path to the audio file (WAV, MP3, MP4, FLAC).
|
|
42
|
+
model: Whisper model to use.
|
|
43
|
+
response_format: Output format — ``"text"``, ``"vtt"``, or ``"srt"``.
|
|
44
|
+
language: Optional language hint (e.g. ``"de"``, ``"en"``).
|
|
45
|
+
wait: If ``True`` (default), block until the transcription is
|
|
46
|
+
ready and return it as a string. If ``False``, submit the
|
|
47
|
+
request on a background thread and return a
|
|
48
|
+
:class:`concurrent.futures.Future` immediately so the call
|
|
49
|
+
does not block. Resolve it with ``.result()`` (which
|
|
50
|
+
re-raises any error), poll with ``.done()``, or attach
|
|
51
|
+
``.add_done_callback(...)``.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The transcription string when ``wait=True``; otherwise a
|
|
55
|
+
``Future`` that resolves to that string.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
# Blocking
|
|
60
|
+
text = client.voice.transcribe("meeting.mp3")
|
|
61
|
+
|
|
62
|
+
# Non-blocking — kick it off, do other work, collect later
|
|
63
|
+
fut = client.voice.transcribe("meeting.mp3", wait=False)
|
|
64
|
+
... # other work runs while it transcribes
|
|
65
|
+
text = fut.result() # blocks only now, re-raises on error
|
|
66
|
+
"""
|
|
67
|
+
return self._audio_request(
|
|
68
|
+
"/audio/transcriptions",
|
|
69
|
+
file_path,
|
|
70
|
+
model=model,
|
|
71
|
+
response_format=response_format,
|
|
72
|
+
language=language,
|
|
73
|
+
wait=wait,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def translate(
|
|
77
|
+
self,
|
|
78
|
+
file_path: str | Path,
|
|
79
|
+
*,
|
|
80
|
+
model: str = "whisper-large-v2",
|
|
81
|
+
response_format: str = "text",
|
|
82
|
+
wait: bool = True,
|
|
83
|
+
) -> str | concurrent.futures.Future[str]:
|
|
84
|
+
"""Translate an audio file to English text.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
file_path: Path to the audio file (WAV, MP3, MP4, FLAC).
|
|
88
|
+
model: Whisper model to use.
|
|
89
|
+
response_format: Output format — ``"text"``, ``"vtt"``, or ``"srt"``.
|
|
90
|
+
wait: If ``True`` (default), block until the result is ready and
|
|
91
|
+
return it as a string. If ``False``, submit on a background
|
|
92
|
+
thread and return a :class:`concurrent.futures.Future` that
|
|
93
|
+
resolves to the translation (see :meth:`transcribe`).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The English translation string when ``wait=True``; otherwise a
|
|
97
|
+
``Future`` that resolves to that string.
|
|
98
|
+
"""
|
|
99
|
+
return self._audio_request(
|
|
100
|
+
"/audio/translations",
|
|
101
|
+
file_path,
|
|
102
|
+
model=model,
|
|
103
|
+
response_format=response_format,
|
|
104
|
+
wait=wait,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _new_session(self) -> requests.Session:
|
|
108
|
+
"""Create a fresh Session mirroring the client's auth headers.
|
|
109
|
+
|
|
110
|
+
Backgrounded requests (``wait=False``) use their own Session so they
|
|
111
|
+
never share the client's Session — and its connection pool — across
|
|
112
|
+
threads (``requests.Session`` is not guaranteed thread-safe). Thin
|
|
113
|
+
wrapper around :func:`saia_python._http.new_session_like` so the
|
|
114
|
+
"fresh authed background Session" idiom has a single implementation.
|
|
115
|
+
"""
|
|
116
|
+
return new_session_like(self._session)
|
|
117
|
+
|
|
118
|
+
def _audio_request(
|
|
119
|
+
self,
|
|
120
|
+
endpoint: str,
|
|
121
|
+
file_path: str | Path,
|
|
122
|
+
*,
|
|
123
|
+
model: str,
|
|
124
|
+
response_format: str,
|
|
125
|
+
language: str | None = None,
|
|
126
|
+
wait: bool = True,
|
|
127
|
+
) -> str | concurrent.futures.Future[str]:
|
|
128
|
+
file_path = Path(file_path)
|
|
129
|
+
|
|
130
|
+
def _send(session: requests.Session) -> str:
|
|
131
|
+
data = {"model": model, "response_format": response_format}
|
|
132
|
+
if language:
|
|
133
|
+
data["language"] = language
|
|
134
|
+
|
|
135
|
+
with open(file_path, "rb") as f:
|
|
136
|
+
resp = session.post(
|
|
137
|
+
f"{self._base_url}{endpoint}",
|
|
138
|
+
data=data,
|
|
139
|
+
files={"file": (file_path.name, f)},
|
|
140
|
+
)
|
|
141
|
+
raise_for_status(resp)
|
|
142
|
+
|
|
143
|
+
content_type = resp.headers.get("content-type", "")
|
|
144
|
+
if "json" in content_type:
|
|
145
|
+
body = resp.json()
|
|
146
|
+
if isinstance(body, dict):
|
|
147
|
+
return body.get("text", resp.text)
|
|
148
|
+
return str(body)
|
|
149
|
+
return resp.text
|
|
150
|
+
|
|
151
|
+
if wait:
|
|
152
|
+
return _send(self._session)
|
|
153
|
+
|
|
154
|
+
# Non-blocking: run on a worker thread with its OWN Session and hand
|
|
155
|
+
# back a Future. The caller resolves it with .result() (which
|
|
156
|
+
# re-raises any error), polls with .done(), or attaches a callback —
|
|
157
|
+
# so the result is never lost the way a bare fire-and-forget loses it.
|
|
158
|
+
future: concurrent.futures.Future[str] = concurrent.futures.Future()
|
|
159
|
+
|
|
160
|
+
def _worker() -> None:
|
|
161
|
+
if not future.set_running_or_notify_cancel():
|
|
162
|
+
return
|
|
163
|
+
session = self._new_session()
|
|
164
|
+
try:
|
|
165
|
+
future.set_result(_send(session))
|
|
166
|
+
except BaseException as exc: # surfaced to the caller via .result()
|
|
167
|
+
future.set_exception(exc)
|
|
168
|
+
finally:
|
|
169
|
+
session.close()
|
|
170
|
+
|
|
171
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
172
|
+
return future
|
|
173
|
+
|
|
174
|
+
def __repr__(self):
|
|
175
|
+
return f"VoiceService(base_url={self._base_url!r})"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saia-python
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Python wrapper for the GWDG SAIA platform REST API
|
|
5
|
+
Author: Friedrich Schwarz
|
|
6
|
+
License-Expression: AGPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/fschwar4/saia_python
|
|
8
|
+
Project-URL: Documentation, https://fschwar4.github.io/saia_python/
|
|
9
|
+
Project-URL: Repository, https://github.com/fschwar4/saia_python
|
|
10
|
+
Project-URL: Changelog, https://github.com/fschwar4/saia_python/blob/main/docs/CHANGELOG.md
|
|
11
|
+
Project-URL: Issues, https://github.com/fschwar4/saia_python/issues
|
|
12
|
+
Keywords: GWDG,SAIA,ARCANA,RAG,LLM,OpenAI,academic-cloud,whisper,AI
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: requests>=2.28
|
|
30
|
+
Requires-Dist: tqdm>=4.60
|
|
31
|
+
Requires-Dist: tomlkit>=0.12
|
|
32
|
+
Provides-Extra: openai
|
|
33
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
36
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
37
|
+
Requires-Dist: saia-python[openai]; extra == "test"
|
|
38
|
+
Provides-Extra: docs
|
|
39
|
+
Requires-Dist: sphinx>=7.0; extra == "docs"
|
|
40
|
+
Requires-Dist: pydata-sphinx-theme>=0.15; extra == "docs"
|
|
41
|
+
Requires-Dist: sphinx-design; extra == "docs"
|
|
42
|
+
Requires-Dist: sphinx-copybutton; extra == "docs"
|
|
43
|
+
Requires-Dist: myst-parser; extra == "docs"
|
|
44
|
+
Provides-Extra: lint
|
|
45
|
+
Requires-Dist: ruff>=0.6; extra == "lint"
|
|
46
|
+
Requires-Dist: mypy>=1.10; extra == "lint"
|
|
47
|
+
Requires-Dist: types-requests; extra == "lint"
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: saia-python[docs,lint,test]; extra == "dev"
|
|
50
|
+
Requires-Dist: jupyter; extra == "dev"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# saia-python
|
|
54
|
+
|
|
55
|
+
[](https://pypi.org/project/saia-python/)
|
|
56
|
+
[](https://pypi.org/project/saia-python/)
|
|
57
|
+
[](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
|
|
58
|
+
[](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
|
|
59
|
+
[](https://fschwar4.github.io/saia_python/)
|
|
60
|
+
<!-- After enabling Zenodo (Settings → Integrations → GitHub) and cutting a release,
|
|
61
|
+
paste the DOI badge Zenodo provides, e.g.:
|
|
62
|
+
[](https://doi.org/10.5281/zenodo.XXXXXXX) -->
|
|
63
|
+
|
|
64
|
+
A Python wrapper for the [GWDG SAIA (Scalable AI Accelerator) platform](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) REST API.
|
|
65
|
+
|
|
66
|
+
SAIA provides self-hosted, OpenAI-compatible AI services at GWDG, including chat completions, voice transcription/translation, document conversion, and RAG (ARCANA). This library wraps the REST API so you can use it from Python — both as an object-oriented client and as standalone functions.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install saia-python
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or from source:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git clone https://github.com/fschwar4/saia_python.git
|
|
78
|
+
cd saia_python
|
|
79
|
+
pip install -e .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quick Start
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from saia_python import SAIAClient
|
|
86
|
+
|
|
87
|
+
# API key auto-discovered from SAIA_API_KEY env var, .saia_api, or .env file
|
|
88
|
+
client = SAIAClient()
|
|
89
|
+
|
|
90
|
+
# List available models
|
|
91
|
+
print(client.models.list_ids())
|
|
92
|
+
|
|
93
|
+
# Chat completion
|
|
94
|
+
response = client.chat.completions(
|
|
95
|
+
model="meta-llama-3.1-8b-instruct",
|
|
96
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
97
|
+
)
|
|
98
|
+
print(response["choices"][0]["message"]["content"])
|
|
99
|
+
|
|
100
|
+
# Check your rate limits
|
|
101
|
+
print(client.get_rate_limits())
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
All services are also available as standalone functions:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from saia_python import list_model_ids, chat_completion
|
|
108
|
+
|
|
109
|
+
list_model_ids()
|
|
110
|
+
chat_completion(model="meta-llama-3.1-8b-instruct", messages=[...])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Supported Services
|
|
114
|
+
|
|
115
|
+
| Service | Description | GWDG Docs |
|
|
116
|
+
|---------|-------------|-----------|
|
|
117
|
+
| **Chat AI** | Chat completions with streaming and tool calling | [Chat AI](https://docs.hpc.gwdg.de/services/ai-services/chat-ai/index.html) |
|
|
118
|
+
| **Voice AI** | Audio transcription and translation (Whisper) | [Voice AI](https://docs.hpc.gwdg.de/services/ai-services/voice-ai/index.html) |
|
|
119
|
+
| **ARCANA** | RAG — knowledge base management and retrieval-augmented chat | [ARCANA](https://docs.hpc.gwdg.de/services/ai-services/arcana/index.html) |
|
|
120
|
+
| **Documents** | PDF/document conversion via Docling | [SAIA API](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) |
|
|
121
|
+
| **Models** | List available models, probe tool-calling support | [SAIA API](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) |
|
|
122
|
+
| **Rate Limits** | Inspect current quota and usage | [SAIA API](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) |
|
|
123
|
+
|
|
124
|
+
## Repository Structure
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
saia-python/
|
|
128
|
+
├── saia_python/ # Main package
|
|
129
|
+
│ ├── __init__.py # Public API, version, functional wrappers
|
|
130
|
+
│ ├── client.py # SAIAClient — composes all services
|
|
131
|
+
│ ├── chat.py # ChatService — completions + streaming
|
|
132
|
+
│ ├── voice.py # VoiceService — transcribe + translate
|
|
133
|
+
│ ├── arcana.py # ArcanaService — RAG / knowledge bases
|
|
134
|
+
│ ├── models.py # ModelsService — list available models
|
|
135
|
+
│ ├── documents.py # DocumentService — Docling conversion
|
|
136
|
+
│ ├── openai_compat.py # OpenAI SDK compatibility layer
|
|
137
|
+
│ ├── auth.py # Credential and config discovery
|
|
138
|
+
│ ├── rate_limits.py # RateLimitInfo dataclass + parser
|
|
139
|
+
│ ├── exceptions.py # SAIAError hierarchy + raise_for_status
|
|
140
|
+
│ ├── _streaming.py # Shared SSE iterator
|
|
141
|
+
│ └── py.typed # PEP 561 typing marker
|
|
142
|
+
├── docs/ # Sphinx documentation (PyData theme)
|
|
143
|
+
│ ├── conf.py
|
|
144
|
+
│ ├── index.rst
|
|
145
|
+
│ ├── quickstart.rst
|
|
146
|
+
│ ├── explanations.rst
|
|
147
|
+
│ ├── architecture.rst
|
|
148
|
+
│ ├── implementation.rst
|
|
149
|
+
│ ├── configuration.rst
|
|
150
|
+
│ ├── api/ # API reference (one page per module)
|
|
151
|
+
│ ├── development.rst
|
|
152
|
+
│ ├── dev_notes.rst
|
|
153
|
+
│ ├── testing.rst
|
|
154
|
+
│ ├── roadmap.rst
|
|
155
|
+
│ └── CHANGELOG.md
|
|
156
|
+
├── tests/ # Unit tests
|
|
157
|
+
├── examples/
|
|
158
|
+
│ ├── saia_python_demo.ipynb # Interactive demo
|
|
159
|
+
│ ├── openai_compatible_proxy.ipynb # OpenAI-compatible proxy example
|
|
160
|
+
│ ├── config.toml.example # Template for structured config
|
|
161
|
+
│ └── .env.example # Template for secrets (.env)
|
|
162
|
+
├── .github/workflows/ # CI/CD (tests, docs, PyPI publish)
|
|
163
|
+
├── pyproject.toml # Package metadata + dependencies
|
|
164
|
+
├── CITATION.cff # Citation metadata (CFF 1.2.0)
|
|
165
|
+
├── .gitignore
|
|
166
|
+
└── README.md
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Documentation
|
|
170
|
+
|
|
171
|
+
Online documentation: <https://fschwar4.github.io/saia_python/>
|
|
172
|
+
|
|
173
|
+
Build the docs locally:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pip install -e ".[docs]"
|
|
177
|
+
sphinx-build -b html -w warnings_sphinx_build.txt docs docs/_build/html
|
|
178
|
+
python3 -m http.server 8000 --directory docs/_build/html
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Citation
|
|
182
|
+
|
|
183
|
+
If you use `saia-python` in your work, please cite it. Citation metadata lives in
|
|
184
|
+
[`CITATION.cff`](https://github.com/fschwar4/saia_python/blob/main/CITATION.cff); GitHub's "Cite this repository" button renders it
|
|
185
|
+
as APA or BibTeX. A Zenodo DOI will be added here once the first release is
|
|
186
|
+
archived.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
[AGPL-3.0-only](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
saia_python/__init__.py,sha256=GY3m01r5-kIwKGVQXwGbGAUWXLytDxsYt7c7yY5okyk,6377
|
|
2
|
+
saia_python/_http.py,sha256=6CNcpFP1u5ANzeDMb_QtYSwWe-fHG1no1nCNLSwzLz0,2793
|
|
3
|
+
saia_python/_streaming.py,sha256=e0LhJ-0yzrqC1Lzi_Rn0Xy8mBNGl6u2mAT1rMQGHqHc,2880
|
|
4
|
+
saia_python/_util.py,sha256=1QrgVxAEK6CwJTNK36qLv9GKTs3ySqg4FJKjx_ZEolM,867
|
|
5
|
+
saia_python/arcana.py,sha256=zYXVkTNggLCo_EtTiNm5W1Y8HIupl4CsrBK39EMHzuE,39732
|
|
6
|
+
saia_python/arcana_references.py,sha256=wVhbsQdqjtKaiEJu0Lpp26Nbqne7rXgHNE6qKH6cSfA,6818
|
|
7
|
+
saia_python/auth.py,sha256=iKKT7hI3oPtz_rq-wLj-S-_XT7RIGIFgeNrY5nCNNB4,16432
|
|
8
|
+
saia_python/chat.py,sha256=KsImp7mXCXAmzFtHRoxm8MMmn5X6zl-0_5xl5JYaOeo,2395
|
|
9
|
+
saia_python/client.py,sha256=yqjN1kv-nBQ81YaMNEtU07AbGQl6s-7aVQN-MwSO4wQ,8432
|
|
10
|
+
saia_python/documents.py,sha256=GG3DnfT6a4p5mPjxddqVr5yOAkceAH59j4UIkvTHrIs,4790
|
|
11
|
+
saia_python/exceptions.py,sha256=_eY-JSfubWvYkKV7GVbFrdHip7MfwdHwL6In-MbP9X8,2074
|
|
12
|
+
saia_python/models.py,sha256=XrMJ_ROs9k2SGAMg_goNpRh4CCpd0KrZP_7qOkNQtIs,4925
|
|
13
|
+
saia_python/openai_compat.py,sha256=RkZp-uKLpOk-UlTMpQpF_E-1_LtiAbUC4IqLWBOjM5k,2349
|
|
14
|
+
saia_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
saia_python/rate_limits.py,sha256=ITIF8CQkHpPVT1d_zEiFNXSt6uFQYagp9QE3C1i_m6Q,2841
|
|
16
|
+
saia_python/responses.py,sha256=alemqTlwpeVS3u7W_DG0HG-KCW19ficjpP5Cs6GvqR8,2206
|
|
17
|
+
saia_python/voice.py,sha256=hI365P1CLW3R2fwZrK2IHnr5bL4nXU570wfQ9Xd4fRQ,6313
|
|
18
|
+
saia_python-0.4.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
19
|
+
saia_python-0.4.1.dist-info/METADATA,sha256=t7dasMAaMqJxWTLraOBzjjxt4jo_R6tfSfEpYgSvIlU,8338
|
|
20
|
+
saia_python-0.4.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
21
|
+
saia_python-0.4.1.dist-info/top_level.txt,sha256=xQzdM-ir7Don4KJtPWWUkyLX9Ry2rai2OrnaWc7GMKw,12
|
|
22
|
+
saia_python-0.4.1.dist-info/RECORD,,
|