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.
Files changed (62) hide show
  1. {saia_python-0.9.0/saia_python.egg-info → saia_python-0.10.1}/PKG-INFO +4 -3
  2. {saia_python-0.9.0 → saia_python-0.10.1}/README.md +2 -2
  3. {saia_python-0.9.0 → saia_python-0.10.1}/pyproject.toml +8 -1
  4. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/__init__.py +12 -1
  5. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/aio.py +35 -2
  6. saia_python-0.10.1/saia_python/chat.py +141 -0
  7. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/client.py +1 -1
  8. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/exceptions.py +26 -0
  9. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/openai_compat.py +1 -1
  10. saia_python-0.10.1/saia_python/structured.py +130 -0
  11. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/tokenizer.py +60 -40
  12. {saia_python-0.9.0 → saia_python-0.10.1/saia_python.egg-info}/PKG-INFO +4 -3
  13. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/SOURCES.txt +4 -0
  14. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/requires.txt +1 -0
  15. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_streaming.py +24 -0
  16. saia_python-0.10.1/tests/test_live_responses_route.py +61 -0
  17. saia_python-0.10.1/tests/test_live_structured.py +50 -0
  18. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_streaming.py +24 -0
  19. saia_python-0.10.1/tests/test_structured.py +184 -0
  20. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_tokenizer.py +13 -2
  21. saia_python-0.9.0/saia_python/chat.py +0 -81
  22. {saia_python-0.9.0 → saia_python-0.10.1}/LICENSE +0 -0
  23. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_async_http.py +0 -0
  24. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_async_streaming.py +0 -0
  25. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_http.py +0 -0
  26. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_payloads.py +0 -0
  27. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_streaming.py +0 -0
  28. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/_util.py +0 -0
  29. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/arcana.py +0 -0
  30. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/arcana_references.py +0 -0
  31. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/auth.py +0 -0
  32. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/documents.py +0 -0
  33. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/models.py +0 -0
  34. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/py.typed +0 -0
  35. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/rate_limits.py +0 -0
  36. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/responses.py +0 -0
  37. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python/voice.py +0 -0
  38. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/dependency_links.txt +0 -0
  39. {saia_python-0.9.0 → saia_python-0.10.1}/saia_python.egg-info/top_level.txt +0 -0
  40. {saia_python-0.9.0 → saia_python-0.10.1}/setup.cfg +0 -0
  41. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_arcana.py +0 -0
  42. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_arcana_references.py +0 -0
  43. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_arcana.py +0 -0
  44. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_chat.py +0 -0
  45. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_client.py +0 -0
  46. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_httpx_integration.py +0 -0
  47. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_async_transport.py +0 -0
  48. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_auth.py +0 -0
  49. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_chat.py +0 -0
  50. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_client.py +0 -0
  51. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_documents.py +0 -0
  52. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_exceptions.py +0 -0
  53. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_health_check.py +0 -0
  54. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_models.py +0 -0
  55. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_openai_compat.py +0 -0
  56. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_payloads.py +0 -0
  57. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_rate_limit_message.py +0 -0
  58. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_rate_limits.py +0 -0
  59. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_responses.py +0 -0
  60. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_setup_from_directory.py +0 -0
  61. {saia_python-0.9.0 → saia_python-0.10.1}/tests/test_transport_policy.py +0 -0
  62. {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.9.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="openai-gpt-oss-120b",
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 and tool calling | [Chat AI](https://docs.hpc.gwdg.de/services/ai-services/chat-ai/index.html) |
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="openai-gpt-oss-120b",
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 and tool calling | [Chat AI](https://docs.hpc.gwdg.de/services/ai-services/chat-ai/index.html) |
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.9.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 APIError, AuthenticationError, RateLimitError, SAIAError
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="openai-gpt-oss-120b",
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})"
@@ -154,7 +154,7 @@ class SAIAClient:
154
154
  Example::
155
155
 
156
156
  response = client.openai.chat.completions.create(
157
- model="llama-3.3-70b-instruct",
157
+ model="deepseek-v4-flash-0731",
158
158
  messages=[{"role": "user", "content": "Hello!"}],
159
159
  )
160
160
  """
@@ -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
 
@@ -43,7 +43,7 @@ def create_openai_client(
43
43
 
44
44
  client = create_openai_client()
45
45
  response = client.chat.completions.create(
46
- model="llama-3.3-70b-instruct",
46
+ model="deepseek-v4-flash-0731",
47
47
  messages=[{"role": "user", "content": "Hello!"}],
48
48
  )
49
49
 
@@ -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 passed as
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. Only
59
- # open-weight models are listed — the externally hosted, proprietary models
60
- # (GPT-5.x, o3, Claude, ...) have no downloadable tokenizer; see
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. ``"openai-gpt-oss-120b"``) — exactly as
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. ``"GPT OSS 120B"``) or a loose spelling of
297
- an id — matched after normalisation (case / punctuation insensitive).
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.