saia-python 0.9.0__tar.gz → 0.10.1__tar.gz
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-0.9.0/saia_python.egg-info → saia_python-0.10.1}/PKG-INFO +4 -3
- {saia_python-0.9.0 → saia_python-0.10.1}/README.md +2 -2
- {saia_python-0.9.0 → saia_python-0.10.1}/pyproject.toml +8 -1
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/__init__.py +12 -1
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/aio.py +35 -2
- saia_python-0.10.1/saia_python/chat.py +141 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/client.py +1 -1
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/exceptions.py +26 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/openai_compat.py +1 -1
- saia_python-0.10.1/saia_python/structured.py +130 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/tokenizer.py +60 -40
- {saia_python-0.9.0 → saia_python-0.10.1/saia_python.egg-info}/PKG-INFO +4 -3
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/SOURCES.txt +4 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/requires.txt +1 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_streaming.py +24 -0
- saia_python-0.10.1/tests/test_live_responses_route.py +61 -0
- saia_python-0.10.1/tests/test_live_structured.py +50 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_streaming.py +24 -0
- saia_python-0.10.1/tests/test_structured.py +184 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_tokenizer.py +13 -2
- saia_python-0.9.0/saia_python/chat.py +0 -81
- {saia_python-0.9.0 → saia_python-0.10.1}/LICENSE +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_async_http.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_async_streaming.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_http.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_payloads.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_streaming.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_util.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/arcana.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/arcana_references.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/auth.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/documents.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/models.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/py.typed +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/rate_limits.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/responses.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/voice.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/dependency_links.txt +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/top_level.txt +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/setup.cfg +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_arcana.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_arcana_references.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_arcana.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_chat.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_client.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_httpx_integration.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_transport.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_auth.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_chat.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_client.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_documents.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_exceptions.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_health_check.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_models.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_openai_compat.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_payloads.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_rate_limit_message.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_rate_limits.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_responses.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_setup_from_directory.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_transport_policy.py +0 -0
- {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_voice.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: saia-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
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
|
|
@@ -42,6 +42,7 @@ Requires-Dist: sentencepiece>=0.1.99; extra == "tokenizer"
|
|
|
42
42
|
Provides-Extra: test
|
|
43
43
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
44
44
|
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
45
|
+
Requires-Dist: pydantic>=2; extra == "test"
|
|
45
46
|
Requires-Dist: saia-python[async,openai]; extra == "test"
|
|
46
47
|
Provides-Extra: docs
|
|
47
48
|
Requires-Dist: sphinx>=7.0; extra == "docs"
|
|
@@ -136,7 +137,7 @@ async def main():
|
|
|
136
137
|
async with AsyncSAIAClient() as client:
|
|
137
138
|
# Non-streaming RAG chat
|
|
138
139
|
answer = await client.arcana.chat(
|
|
139
|
-
model="
|
|
140
|
+
model="deepseek-v4-flash-0731",
|
|
140
141
|
messages=[{"role": "user", "content": "Summarise the DLBCL first line."}],
|
|
141
142
|
arcana_id="owner/kb",
|
|
142
143
|
)
|
|
@@ -166,7 +167,7 @@ remain synchronous on `SAIAClient` — see
|
|
|
166
167
|
|
|
167
168
|
| Service | Description | GWDG Docs |
|
|
168
169
|
|---------|-------------|-----------|
|
|
169
|
-
| **Chat AI** | Chat completions with streaming
|
|
170
|
+
| **Chat AI** | Chat completions with streaming, tool calling, and structured output (Pydantic) | [Chat AI](https://docs.hpc.gwdg.de/services/ai-services/chat-ai/index.html) |
|
|
170
171
|
| **Voice AI** | Audio transcription and translation (Whisper) | [Voice AI](https://docs.hpc.gwdg.de/services/ai-services/voice-ai/index.html) |
|
|
171
172
|
| **ARCANA** | RAG — knowledge base management and retrieval-augmented chat | [ARCANA](https://docs.hpc.gwdg.de/services/ai-services/arcana/index.html) |
|
|
172
173
|
| **Documents** | PDF/document conversion via Docling | [SAIA API](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) |
|
|
@@ -76,7 +76,7 @@ async def main():
|
|
|
76
76
|
async with AsyncSAIAClient() as client:
|
|
77
77
|
# Non-streaming RAG chat
|
|
78
78
|
answer = await client.arcana.chat(
|
|
79
|
-
model="
|
|
79
|
+
model="deepseek-v4-flash-0731",
|
|
80
80
|
messages=[{"role": "user", "content": "Summarise the DLBCL first line."}],
|
|
81
81
|
arcana_id="owner/kb",
|
|
82
82
|
)
|
|
@@ -106,7 +106,7 @@ remain synchronous on `SAIAClient` — see
|
|
|
106
106
|
|
|
107
107
|
| Service | Description | GWDG Docs |
|
|
108
108
|
|---------|-------------|-----------|
|
|
109
|
-
| **Chat AI** | Chat completions with streaming
|
|
109
|
+
| **Chat AI** | Chat completions with streaming, tool calling, and structured output (Pydantic) | [Chat AI](https://docs.hpc.gwdg.de/services/ai-services/chat-ai/index.html) |
|
|
110
110
|
| **Voice AI** | Audio transcription and translation (Whisper) | [Voice AI](https://docs.hpc.gwdg.de/services/ai-services/voice-ai/index.html) |
|
|
111
111
|
| **ARCANA** | RAG — knowledge base management and retrieval-augmented chat | [ARCANA](https://docs.hpc.gwdg.de/services/ai-services/arcana/index.html) |
|
|
112
112
|
| **Documents** | PDF/document conversion via Docling | [SAIA API](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "saia-python"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.1"
|
|
8
8
|
description = "Python wrapper for the GWDG SAIA platform REST API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -81,6 +81,8 @@ tokenizer = [
|
|
|
81
81
|
test = [
|
|
82
82
|
"pytest>=7.0",
|
|
83
83
|
"pytest-cov>=4.0",
|
|
84
|
+
# the structured-output tests define Pydantic v2 models
|
|
85
|
+
"pydantic>=2",
|
|
84
86
|
"saia-python[openai,async]",
|
|
85
87
|
]
|
|
86
88
|
docs = [
|
|
@@ -132,3 +134,8 @@ disable_error_code = ["index", "operator", "union-attr", "call-arg"]
|
|
|
132
134
|
# `builtins.list`).
|
|
133
135
|
module = ["saia_python.models", "saia_python.arcana", "saia_python.aio"]
|
|
134
136
|
disable_error_code = ["valid-type"]
|
|
137
|
+
|
|
138
|
+
[dependency-groups]
|
|
139
|
+
dev = [
|
|
140
|
+
"ipykernel>=7.3.0",
|
|
141
|
+
]
|
|
@@ -44,10 +44,17 @@ from .auth import (
|
|
|
44
44
|
)
|
|
45
45
|
from .client import SAIAClient
|
|
46
46
|
from .documents import ConversionImage, ConversionResult
|
|
47
|
-
from .exceptions import
|
|
47
|
+
from .exceptions import (
|
|
48
|
+
APIError,
|
|
49
|
+
AuthenticationError,
|
|
50
|
+
RateLimitError,
|
|
51
|
+
SAIAError,
|
|
52
|
+
StructuredOutputError,
|
|
53
|
+
)
|
|
48
54
|
from .openai_compat import create_openai_client
|
|
49
55
|
from .rate_limits import RateLimitInfo, format_rate_limit_error, parse_rate_limits
|
|
50
56
|
from .responses import text_of
|
|
57
|
+
from .structured import parse_structured, response_format_for
|
|
51
58
|
from .tokenizer import (
|
|
52
59
|
DEFAULT_TOKENIZER_DIR,
|
|
53
60
|
GWDG_MODEL_REPOS,
|
|
@@ -98,6 +105,7 @@ __all__ = [
|
|
|
98
105
|
"AuthenticationError",
|
|
99
106
|
"RateLimitError",
|
|
100
107
|
"APIError",
|
|
108
|
+
"StructuredOutputError",
|
|
101
109
|
# Rate limits
|
|
102
110
|
"RateLimitInfo",
|
|
103
111
|
"parse_rate_limits",
|
|
@@ -110,6 +118,9 @@ __all__ = [
|
|
|
110
118
|
# Response helpers
|
|
111
119
|
"text_of",
|
|
112
120
|
"SSEStream",
|
|
121
|
+
# Structured output (Pydantic v2 models)
|
|
122
|
+
"response_format_for",
|
|
123
|
+
"parse_structured",
|
|
113
124
|
# ARCANA reference parsing
|
|
114
125
|
"ArcanaReference",
|
|
115
126
|
"ParsedReferences",
|
|
@@ -12,7 +12,7 @@ calls and ``async for`` over a stream::
|
|
|
12
12
|
async with AsyncSAIAClient() as client:
|
|
13
13
|
# non-streaming RAG chat
|
|
14
14
|
answer = await client.arcana.chat(
|
|
15
|
-
model="
|
|
15
|
+
model="deepseek-v4-flash-0731",
|
|
16
16
|
messages=[{"role": "user", "content": "..."}],
|
|
17
17
|
arcana_id="owner/kb",
|
|
18
18
|
)
|
|
@@ -41,7 +41,7 @@ the two transports cannot drift.
|
|
|
41
41
|
|
|
42
42
|
from __future__ import annotations
|
|
43
43
|
|
|
44
|
-
from typing import TYPE_CHECKING, Any
|
|
44
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
45
45
|
|
|
46
46
|
from ._async_http import aexecute, apost_chat_completion
|
|
47
47
|
from ._async_streaming import AsyncSSEStream
|
|
@@ -54,6 +54,7 @@ from ._payloads import (
|
|
|
54
54
|
from .auth import resolve_credentials
|
|
55
55
|
from .exceptions import raise_for_status
|
|
56
56
|
from .rate_limits import RateLimitInfo, parse_rate_limits
|
|
57
|
+
from .structured import ModelT, parse_structured, response_format_for
|
|
57
58
|
|
|
58
59
|
if TYPE_CHECKING:
|
|
59
60
|
import httpx
|
|
@@ -140,6 +141,38 @@ class AsyncChatService:
|
|
|
140
141
|
policy=resolve_retry(self._retry, retry),
|
|
141
142
|
)
|
|
142
143
|
|
|
144
|
+
async def completions_structured(
|
|
145
|
+
self,
|
|
146
|
+
model: str,
|
|
147
|
+
messages: list[dict],
|
|
148
|
+
response_model: type[ModelT],
|
|
149
|
+
*,
|
|
150
|
+
temperature: float | None = None,
|
|
151
|
+
top_p: float | None = None,
|
|
152
|
+
max_tokens: int | None = None,
|
|
153
|
+
retry: RetryPolicy | bool | None = None,
|
|
154
|
+
**kwargs: Any,
|
|
155
|
+
) -> ModelT:
|
|
156
|
+
"""Return the answer as a validated ``response_model`` instance.
|
|
157
|
+
|
|
158
|
+
See :meth:`ChatService.completions_structured
|
|
159
|
+
<saia_python.chat.ChatService.completions_structured>`; raises
|
|
160
|
+
:class:`~saia_python.StructuredOutputError` when there is no answer
|
|
161
|
+
that validates.
|
|
162
|
+
"""
|
|
163
|
+
response = await self.completions(
|
|
164
|
+
model,
|
|
165
|
+
messages,
|
|
166
|
+
temperature=temperature,
|
|
167
|
+
top_p=top_p,
|
|
168
|
+
max_tokens=max_tokens,
|
|
169
|
+
stream=False,
|
|
170
|
+
retry=retry,
|
|
171
|
+
response_format=response_format_for(response_model),
|
|
172
|
+
**kwargs,
|
|
173
|
+
)
|
|
174
|
+
return parse_structured(cast(dict, response), response_model)
|
|
175
|
+
|
|
143
176
|
def __repr__(self) -> str:
|
|
144
177
|
return f"AsyncChatService(base_url={self._base_url!r})"
|
|
145
178
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Chat service — completions and streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from ._http import RetryPolicy, coerce_retry, post_chat_completion, resolve_retry
|
|
8
|
+
from ._streaming import SSEStream
|
|
9
|
+
from .structured import ModelT, parse_structured, response_format_for
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChatService:
|
|
16
|
+
"""Access the ``/chat/completions`` endpoint.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
session: A :class:`requests.Session` with auth headers configured.
|
|
20
|
+
base_url: The SAIA API base URL.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
session: requests.Session,
|
|
26
|
+
base_url: str,
|
|
27
|
+
*,
|
|
28
|
+
retry: RetryPolicy | bool | None = None,
|
|
29
|
+
):
|
|
30
|
+
self._session = session
|
|
31
|
+
self._base_url = base_url
|
|
32
|
+
self._retry = coerce_retry(retry)
|
|
33
|
+
|
|
34
|
+
def completions(
|
|
35
|
+
self,
|
|
36
|
+
model: str,
|
|
37
|
+
messages: list[dict],
|
|
38
|
+
*,
|
|
39
|
+
temperature: float | None = None,
|
|
40
|
+
top_p: float | None = None,
|
|
41
|
+
max_tokens: int | None = None,
|
|
42
|
+
stream: bool = False,
|
|
43
|
+
retry: RetryPolicy | bool | None = None,
|
|
44
|
+
**kwargs,
|
|
45
|
+
) -> dict | SSEStream:
|
|
46
|
+
"""Send a chat completion request.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
model: Model identifier (e.g. ``"meta-llama-3.1-8b-instruct"``).
|
|
50
|
+
messages: List of message dicts with ``"role"`` and ``"content"`` keys.
|
|
51
|
+
temperature: Sampling temperature (0–2).
|
|
52
|
+
top_p: Nucleus sampling parameter (0–1).
|
|
53
|
+
max_tokens: Maximum tokens to generate.
|
|
54
|
+
stream: If ``True``, return a generator yielding chunks.
|
|
55
|
+
**kwargs: Additional parameters forwarded to the API.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
When ``stream=False``: the API response dict, with an extra
|
|
59
|
+
``"_rate_limits"`` key — a JSON-serializable dict of the current
|
|
60
|
+
rate-limit headers (see :class:`~saia_python.RateLimitInfo`).
|
|
61
|
+
When ``stream=True``: an ``SSEStream`` — iterate it for the
|
|
62
|
+
response chunks; its ``rate_limits`` attribute exposes the same
|
|
63
|
+
dict (available immediately, from the response headers).
|
|
64
|
+
"""
|
|
65
|
+
body = {"model": model, "messages": messages, **kwargs}
|
|
66
|
+
if temperature is not None:
|
|
67
|
+
body["temperature"] = temperature
|
|
68
|
+
if top_p is not None:
|
|
69
|
+
body["top_p"] = top_p
|
|
70
|
+
if max_tokens is not None:
|
|
71
|
+
body["max_tokens"] = max_tokens
|
|
72
|
+
|
|
73
|
+
return post_chat_completion(
|
|
74
|
+
self._session,
|
|
75
|
+
f"{self._base_url}/chat/completions",
|
|
76
|
+
body,
|
|
77
|
+
stream=stream,
|
|
78
|
+
policy=resolve_retry(self._retry, retry),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def completions_structured(
|
|
82
|
+
self,
|
|
83
|
+
model: str,
|
|
84
|
+
messages: list[dict],
|
|
85
|
+
response_model: type[ModelT],
|
|
86
|
+
*,
|
|
87
|
+
temperature: float | None = None,
|
|
88
|
+
top_p: float | None = None,
|
|
89
|
+
max_tokens: int | None = None,
|
|
90
|
+
retry: RetryPolicy | bool | None = None,
|
|
91
|
+
**kwargs,
|
|
92
|
+
) -> ModelT:
|
|
93
|
+
"""Send a chat completion and return the answer as a validated model.
|
|
94
|
+
|
|
95
|
+
Sends ``response_model``'s JSON Schema as the ``response_format`` (see
|
|
96
|
+
:func:`~saia_python.response_format_for`), which SAIA enforces on the
|
|
97
|
+
server, then validates the answer into an instance (see
|
|
98
|
+
:func:`~saia_python.parse_structured`). Non-streaming only. To keep the
|
|
99
|
+
raw response too (``usage``, ``_rate_limits``), call those two helpers
|
|
100
|
+
around :meth:`completions` yourself.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
model: Model identifier (e.g. ``"meta-llama-3.1-8b-instruct"``).
|
|
104
|
+
messages: List of message dicts with ``"role"`` and ``"content"`` keys.
|
|
105
|
+
SAIA uses the schema only to constrain the output and does not
|
|
106
|
+
add it to the prompt, so say what each field should hold.
|
|
107
|
+
response_model: The Pydantic v2 model class the answer must match.
|
|
108
|
+
temperature: Sampling temperature (0–2).
|
|
109
|
+
top_p: Nucleus sampling parameter (0–1).
|
|
110
|
+
max_tokens: Maximum tokens to generate. Reasoning models think
|
|
111
|
+
before they answer, so leave room, or turn thinking off where
|
|
112
|
+
the chat template allows it (e.g. Qwen:
|
|
113
|
+
``chat_template_kwargs={"enable_thinking": False}``).
|
|
114
|
+
retry: Overrides the service's rate-limit retry policy for this
|
|
115
|
+
call, as in :meth:`completions`.
|
|
116
|
+
**kwargs: Additional parameters forwarded to the API.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
An instance of ``response_model``.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
StructuredOutputError: The response holds no answer that validates,
|
|
123
|
+
e.g. because the token budget ran out (``finish_reason`` is
|
|
124
|
+
``"length"``). The error keeps the full response, ``usage``
|
|
125
|
+
included.
|
|
126
|
+
"""
|
|
127
|
+
response = self.completions(
|
|
128
|
+
model,
|
|
129
|
+
messages,
|
|
130
|
+
temperature=temperature,
|
|
131
|
+
top_p=top_p,
|
|
132
|
+
max_tokens=max_tokens,
|
|
133
|
+
stream=False,
|
|
134
|
+
retry=retry,
|
|
135
|
+
response_format=response_format_for(response_model),
|
|
136
|
+
**kwargs,
|
|
137
|
+
)
|
|
138
|
+
return parse_structured(cast(dict, response), response_model)
|
|
139
|
+
|
|
140
|
+
def __repr__(self):
|
|
141
|
+
return f"ChatService(base_url={self._base_url!r})"
|
|
@@ -76,6 +76,32 @@ class APIError(SAIAError):
|
|
|
76
76
|
super().__init__(message, status_code=status_code, response_body=response_body)
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
class StructuredOutputError(SAIAError):
|
|
80
|
+
"""Raised when a chat response holds no answer that validates against the
|
|
81
|
+
requested structured-output model.
|
|
82
|
+
|
|
83
|
+
The HTTP call itself succeeded, so ``status_code`` is ``None``; the message
|
|
84
|
+
says why the answer is unusable. The response stays attached, so a caller
|
|
85
|
+
can still read ``usage`` (the tokens were spent) or inspect the raw content.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
response: The chat response dict the answer was read from.
|
|
89
|
+
finish_reason: The first choice's ``finish_reason`` — ``"length"`` means
|
|
90
|
+
the token budget ran out — or ``None`` when there was no choice.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
message: object,
|
|
96
|
+
*,
|
|
97
|
+
response: dict,
|
|
98
|
+
finish_reason: str | None = None,
|
|
99
|
+
):
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
self.response = response
|
|
102
|
+
self.finish_reason = finish_reason
|
|
103
|
+
|
|
104
|
+
|
|
79
105
|
def _extract_detail(resp: Any) -> str:
|
|
80
106
|
"""Try to extract a human-readable message from a JSON error body.
|
|
81
107
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Structured output — a Pydantic model in, a validated instance out.
|
|
2
|
+
|
|
3
|
+
SAIA enforces a ``json_schema`` ``response_format`` on the server: its inference
|
|
4
|
+
backend (vLLM) compiles the schema into a grammar and blocks every token that
|
|
5
|
+
would break it, so the answer matches the schema by construction and needs no
|
|
6
|
+
validate-and-retry loop. This module covers the two client-side ends:
|
|
7
|
+
|
|
8
|
+
- :func:`response_format_for` turns a Pydantic model into the
|
|
9
|
+
``response_format`` request field.
|
|
10
|
+
- :func:`parse_structured` validates a chat response back into the model, and
|
|
11
|
+
raises :class:`~saia_python.StructuredOutputError` with the reason when there
|
|
12
|
+
is nothing usable — most often a reasoning model that spent its
|
|
13
|
+
``max_tokens`` thinking.
|
|
14
|
+
|
|
15
|
+
:meth:`ChatService.completions_structured
|
|
16
|
+
<saia_python.chat.ChatService.completions_structured>` does both in one call.
|
|
17
|
+
Use the two helpers directly when you also need the raw response, e.g. its
|
|
18
|
+
``usage``::
|
|
19
|
+
|
|
20
|
+
resp = client.chat.completions(
|
|
21
|
+
model, messages, response_format=response_format_for(Invoice)
|
|
22
|
+
)
|
|
23
|
+
invoice = parse_structured(resp, Invoice)
|
|
24
|
+
tokens = resp["usage"]["total_tokens"]
|
|
25
|
+
|
|
26
|
+
Needs Pydantic v2, which you already have if you define a model; the package
|
|
27
|
+
itself imports it only inside :func:`parse_structured`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
34
|
+
|
|
35
|
+
from .exceptions import StructuredOutputError
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from pydantic import BaseModel
|
|
39
|
+
|
|
40
|
+
ModelT = TypeVar("ModelT", bound="BaseModel")
|
|
41
|
+
|
|
42
|
+
_TOKEN_HINT = (
|
|
43
|
+
"Reasoning models spend tokens thinking before they answer: raise max_tokens, "
|
|
44
|
+
"or turn thinking off where the chat template allows it (e.g. Qwen: "
|
|
45
|
+
"chat_template_kwargs={'enable_thinking': False})."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def response_format_for(response_model: type[BaseModel]) -> dict:
|
|
50
|
+
"""Build the ``response_format`` request field for a Pydantic model.
|
|
51
|
+
|
|
52
|
+
``strict`` is left unset: SAIA's backends enforce the schema either way,
|
|
53
|
+
whereas OpenAI's strict mode would reject a plain Pydantic schema (it
|
|
54
|
+
requires ``additionalProperties: false`` on every object).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
response_model: A Pydantic v2 model class.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``{"type": "json_schema", "json_schema": {"name": ..., "schema": ...}}``
|
|
61
|
+
with the model's JSON Schema, named after the class (characters outside
|
|
62
|
+
``a-z A-Z 0-9 _ -``, such as a generic's brackets, become ``_``).
|
|
63
|
+
"""
|
|
64
|
+
name = re.sub(r"[^a-zA-Z0-9_-]", "_", response_model.__name__)[:64]
|
|
65
|
+
return {
|
|
66
|
+
"type": "json_schema",
|
|
67
|
+
"json_schema": {"name": name, "schema": response_model.model_json_schema()},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_structured(response: dict, response_model: type[ModelT]) -> ModelT:
|
|
72
|
+
"""Validate a chat response's answer into ``response_model``.
|
|
73
|
+
|
|
74
|
+
Reads the first choice's ``content`` — the JSON string the model wrote — and
|
|
75
|
+
validates it with ``response_model.model_validate_json``. Works on any
|
|
76
|
+
OpenAI-style ChatCompletion dict, such as the one
|
|
77
|
+
:meth:`~saia_python.chat.ChatService.completions` returns.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
response: A chat completion response dict.
|
|
81
|
+
response_model: The Pydantic v2 model class to validate into.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The validated ``response_model`` instance.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
StructuredOutputError: When there is no usable answer: no choices; no
|
|
88
|
+
content (a reasoning model that ran out of tokens while thinking
|
|
89
|
+
returns ``content: None`` with ``finish_reason: "length"``); an
|
|
90
|
+
answer cut off at ``max_tokens``; or one that fails validation, with
|
|
91
|
+
the :class:`pydantic.ValidationError` chained as the cause.
|
|
92
|
+
"""
|
|
93
|
+
from pydantic import ValidationError
|
|
94
|
+
|
|
95
|
+
name = response_model.__name__
|
|
96
|
+
choices = response.get("choices") or []
|
|
97
|
+
if not choices:
|
|
98
|
+
raise StructuredOutputError(
|
|
99
|
+
f"{name}: the response has no choices", response=response
|
|
100
|
+
)
|
|
101
|
+
finish_reason = choices[0].get("finish_reason")
|
|
102
|
+
message = choices[0].get("message") or {}
|
|
103
|
+
content = message.get("content")
|
|
104
|
+
if not content:
|
|
105
|
+
if finish_reason == "length":
|
|
106
|
+
reason = f"the model ran out of tokens before answering. {_TOKEN_HINT}"
|
|
107
|
+
elif message.get("refusal"):
|
|
108
|
+
reason = f"the model refused: {message['refusal']}"
|
|
109
|
+
else:
|
|
110
|
+
reason = f"the response has no content (finish_reason={finish_reason!r})"
|
|
111
|
+
raise StructuredOutputError(
|
|
112
|
+
f"{name}: {reason}", response=response, finish_reason=finish_reason
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
return response_model.model_validate_json(content)
|
|
116
|
+
except ValidationError as exc:
|
|
117
|
+
# Validate before blaming finish_reason: an answer can be complete even
|
|
118
|
+
# when generation hit the limit afterwards (e.g. trailing whitespace).
|
|
119
|
+
if finish_reason == "length":
|
|
120
|
+
reason = f"the answer was cut off at max_tokens. {_TOKEN_HINT}"
|
|
121
|
+
else:
|
|
122
|
+
error = exc.errors()[0]
|
|
123
|
+
where = ".".join(str(part) for part in error["loc"])
|
|
124
|
+
detail = f"{where}: {error['msg']}" if where else error["msg"]
|
|
125
|
+
if exc.error_count() > 1:
|
|
126
|
+
detail += f"; {exc.error_count() - 1} more"
|
|
127
|
+
reason = f"the answer failed validation ({detail})"
|
|
128
|
+
raise StructuredOutputError(
|
|
129
|
+
f"{name}: {reason}", response=response, finish_reason=finish_reason
|
|
130
|
+
) from exc
|
|
@@ -50,16 +50,64 @@ if TYPE_CHECKING: # pragma: no cover - typing only
|
|
|
50
50
|
# ---------------------------------------------------------------------------
|
|
51
51
|
|
|
52
52
|
# Single source of truth: ``(api_model_id, display_name, hf_repo)`` for every
|
|
53
|
-
# open-weight model GWDG hosts. ``api_model_id`` is the string
|
|
54
|
-
# ``"model"`` to the API (and returned as ``id`` by ``GET /models``);
|
|
53
|
+
# open-weight model GWDG hosts or has hosted. ``api_model_id`` is the string
|
|
54
|
+
# passed as ``"model"`` to the API (and returned as ``id`` by ``GET /models``);
|
|
55
55
|
# ``display_name`` is the catalogue's "Model" column (and the ``name`` field of
|
|
56
56
|
# the ``/models`` payload); ``hf_repo`` is the ``org/name`` the catalogue links
|
|
57
57
|
# to on https://huggingface.co . Sourced from the GWDG model catalogue and
|
|
58
|
-
# cross-checked against the live ``/models`` listing on 2026-06-21
|
|
59
|
-
#
|
|
60
|
-
# (GPT-5.x, o3,
|
|
58
|
+
# cross-checked against the live ``/models`` listing on 2026-06-21; the models
|
|
59
|
+
# added on 2026-10-09 come from the catalogue page alone. Only open-weight
|
|
60
|
+
# models are listed — the externally hosted, proprietary models (GPT-5.x, o3,
|
|
61
|
+
# Claude, ...) have no downloadable tokenizer; see
|
|
61
62
|
# :data:`OPENAI_TIKTOKEN_ENCODINGS` for their byte-pair encodings.
|
|
62
63
|
_MODEL_TABLE: list[tuple[str, str, str]] = [
|
|
64
|
+
# Served by GWDG as of 2026-10-09.
|
|
65
|
+
# DeepSeek V4 ships no Jinja chat template (only a Python encoder), so
|
|
66
|
+
# chat_template_tokens falls back to a plain render with a warning for it.
|
|
67
|
+
(
|
|
68
|
+
"deepseek-v4-flash-0731",
|
|
69
|
+
"DeepSeek V4 Flash 0731",
|
|
70
|
+
"deepseek-ai/DeepSeek-V4-Flash-0731",
|
|
71
|
+
),
|
|
72
|
+
("gemma-4-31b-it", "Gemma 4 31B Instruct", "google/gemma-4-31B-it"),
|
|
73
|
+
# Its tokenizer_config names TokenizersBackend, a transformers 5 class.
|
|
74
|
+
("glm-5.3-flash", "GLM 5.3 Flash", "zai-org/GLM-5.3-Flash"),
|
|
75
|
+
(
|
|
76
|
+
"meta-llama-3.1-8b-instruct",
|
|
77
|
+
"Llama 3.1 8B Instruct",
|
|
78
|
+
"nvidia/Llama-3.1-8B-Instruct-FP8",
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"qwen3-30b-a3b-instruct-2507",
|
|
82
|
+
"Qwen 3 30B A3B Instruct 2507",
|
|
83
|
+
"Qwen/Qwen3-30B-A3B-Instruct-2507-FP8",
|
|
84
|
+
),
|
|
85
|
+
("qwen3-coder-next", "Qwen 3 Coder Next", "Qwen/Qwen3-Coder-Next-FP8"),
|
|
86
|
+
(
|
|
87
|
+
"qwen3-omni-30b-a3b-instruct",
|
|
88
|
+
"Qwen 3 Omni 30B A3B Instruct",
|
|
89
|
+
"Qwen/Qwen3-Omni-30B-A3B-Instruct",
|
|
90
|
+
),
|
|
91
|
+
("qwen3.5-397b-a17b", "Qwen 3.5 397B A17B", "Qwen/Qwen3.5-397B-A17B-GPTQ-Int4"),
|
|
92
|
+
("qwen3.6-35b-a3b", "Qwen 3.6 35B A3B", "Qwen/Qwen3.6-35B-A3B-FP8"),
|
|
93
|
+
("qwen3.8-27b", "Qwen 3.8 27B", "Qwen/Qwen3.8-27B-FP8"),
|
|
94
|
+
# Embedding models — served via /embeddings rather than the chat /models
|
|
95
|
+
# listing, but their tokenizers are useful for sizing RAG chunks.
|
|
96
|
+
# ``qwen3-embedding-4b`` is the model ARCANA's RAG pipeline uses internally.
|
|
97
|
+
(
|
|
98
|
+
"qwen3-embedding-4b",
|
|
99
|
+
"Qwen3 Embedding 4B",
|
|
100
|
+
"Qwen/Qwen3-Embedding-4B",
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"e5-mistral-7b-instruct",
|
|
104
|
+
"E5 Mistral 7B Instruct",
|
|
105
|
+
"intfloat/e5-mistral-7b-instruct",
|
|
106
|
+
),
|
|
107
|
+
# No longer served by GWDG (openai-gpt-oss-120b, devstral-2-123b-instruct-2512
|
|
108
|
+
# and apertus-70b-instruct-2509 were retired on 2026-10-08). Kept because
|
|
109
|
+
# their Hugging Face repos remain: the ids still resolve and the tokenizers
|
|
110
|
+
# still download.
|
|
63
111
|
(
|
|
64
112
|
"apertus-70b-instruct-2509",
|
|
65
113
|
"Apertus 70B Instruct 2509",
|
|
@@ -75,57 +123,26 @@ _MODEL_TABLE: list[tuple[str, str, str]] = [
|
|
|
75
123
|
"Devstral 2 123B Instruct 2512",
|
|
76
124
|
"mistralai/Devstral-2-123B-Instruct-2512",
|
|
77
125
|
),
|
|
78
|
-
("gemma-4-31b-it", "Gemma 4 31B Instruct", "google/gemma-4-31B-it"),
|
|
79
126
|
("glm-4.7", "GLM-4.7", "zai-org/GLM-4.7-FP8"),
|
|
80
127
|
("internvl3.5-30b-a3b", "InternVL 3.5 30B A3B", "OpenGVLab/InternVL3_5-30B-A3B-HF"),
|
|
81
128
|
("medgemma-27b-it", "MedGemma 27B Instruct", "google/medgemma-27b-it"),
|
|
82
|
-
(
|
|
83
|
-
"meta-llama-3.1-8b-instruct",
|
|
84
|
-
"Llama 3.1 8B Instruct",
|
|
85
|
-
"nvidia/Llama-3.1-8B-Instruct-FP8",
|
|
86
|
-
),
|
|
87
129
|
(
|
|
88
130
|
"mistral-large-3-675b-instruct-2512",
|
|
89
131
|
"Mistral Large 3 675B Instruct 2512",
|
|
90
132
|
"mistralai/Mistral-Large-3-675B-Instruct-2512-NVFP4",
|
|
91
133
|
),
|
|
92
134
|
("openai-gpt-oss-120b", "GPT OSS 120B", "openai/gpt-oss-120b"),
|
|
93
|
-
(
|
|
94
|
-
"qwen3-30b-a3b-instruct-2507",
|
|
95
|
-
"Qwen 3 30B A3B Instruct 2507",
|
|
96
|
-
"Qwen/Qwen3-30B-A3B-Instruct-2507-FP8",
|
|
97
|
-
),
|
|
98
135
|
(
|
|
99
136
|
"qwen3-coder-30b-a3b-instruct",
|
|
100
137
|
"Qwen 3 Coder 30B A3B Instruct",
|
|
101
138
|
"Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8",
|
|
102
139
|
),
|
|
103
|
-
(
|
|
104
|
-
"qwen3-omni-30b-a3b-instruct",
|
|
105
|
-
"Qwen 3 Omni 30B A3B Instruct",
|
|
106
|
-
"Qwen/Qwen3-Omni-30B-A3B-Instruct",
|
|
107
|
-
),
|
|
108
140
|
("qwen3.5-122b-a10b", "Qwen 3.5 122B A10B", "Qwen/Qwen3.5-122B-A10B-GPTQ-Int4"),
|
|
109
|
-
("qwen3.5-397b-a17b", "Qwen 3.5 397B A17B", "Qwen/Qwen3.5-397B-A17B-GPTQ-Int4"),
|
|
110
|
-
("qwen3.6-35b-a3b", "Qwen 3.6 35B A3B", "Qwen/Qwen3.6-35B-A3B-FP8"),
|
|
111
141
|
(
|
|
112
142
|
"teuken-7b-instruct-research",
|
|
113
143
|
"Teuken 7B Instruct Research",
|
|
114
144
|
"openGPT-X/Teuken-7B-instruct-research-v0.4",
|
|
115
145
|
),
|
|
116
|
-
# Embedding models — served via /embeddings rather than the chat /models
|
|
117
|
-
# listing, but their tokenizers are useful for sizing RAG chunks.
|
|
118
|
-
# ``qwen3-embedding-4b`` is the model ARCANA's RAG pipeline uses internally.
|
|
119
|
-
(
|
|
120
|
-
"qwen3-embedding-4b",
|
|
121
|
-
"Qwen3 Embedding 4B",
|
|
122
|
-
"Qwen/Qwen3-Embedding-4B",
|
|
123
|
-
),
|
|
124
|
-
(
|
|
125
|
-
"e5-mistral-7b-instruct",
|
|
126
|
-
"E5 Mistral 7B Instruct",
|
|
127
|
-
"intfloat/e5-mistral-7b-instruct",
|
|
128
|
-
),
|
|
129
146
|
]
|
|
130
147
|
|
|
131
148
|
#: Mapping of GWDG API model id → Hugging Face ``org/name`` repository.
|
|
@@ -279,7 +296,9 @@ def available_open_models() -> list[str]:
|
|
|
279
296
|
"""Return the GWDG open-weight model ids known to this module.
|
|
280
297
|
|
|
281
298
|
These are the keys of :data:`GWDG_MODEL_REPOS` — the models for which a
|
|
282
|
-
tokenizer repository is published and can be downloaded
|
|
299
|
+
tokenizer repository is published and can be downloaded, including the ones
|
|
300
|
+
GWDG no longer serves. For the models available right now, annotate the live
|
|
301
|
+
listing instead (:meth:`TokenizerService.available_repos`).
|
|
283
302
|
"""
|
|
284
303
|
return list(GWDG_MODEL_REPOS)
|
|
285
304
|
|
|
@@ -289,12 +308,13 @@ def resolve_repo(model: str) -> str:
|
|
|
289
308
|
|
|
290
309
|
Accepts, in order of preference:
|
|
291
310
|
|
|
292
|
-
1. A GWDG API model id (e.g. ``"
|
|
311
|
+
1. A GWDG API model id (e.g. ``"deepseek-v4-flash-0731"``) — exactly as
|
|
293
312
|
returned by ``GET /models`` / passed as ``"model"`` in API calls.
|
|
294
313
|
2. A full ``org/name`` Hugging Face repo (anything containing ``/``) — used
|
|
295
314
|
verbatim, so callers can point at a model this module does not list yet.
|
|
296
|
-
3. A catalogue display name (e.g. ``"
|
|
297
|
-
an id — matched after normalisation (case / punctuation
|
|
315
|
+
3. A catalogue display name (e.g. ``"DeepSeek V4 Flash 0731"``) or a loose
|
|
316
|
+
spelling of an id — matched after normalisation (case / punctuation
|
|
317
|
+
insensitive).
|
|
298
318
|
|
|
299
319
|
Args:
|
|
300
320
|
model: The model id, display name, or ``org/name`` repository.
|