blablador 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blablador/__init__.py +29 -0
- blablador/__main__.py +7 -0
- blablador/_client.py +326 -0
- blablador/_errors.py +104 -0
- blablador/_models.py +127 -0
- blablador/_reasoning.py +33 -0
- blablador/_rerank.py +127 -0
- blablador/_retry.py +97 -0
- blablador/_tools.py +53 -0
- blablador/cli.py +219 -0
- blablador/py.typed +0 -0
- blablador-0.2.0.dist-info/METADATA +256 -0
- blablador-0.2.0.dist-info/RECORD +16 -0
- blablador-0.2.0.dist-info/WHEEL +4 -0
- blablador-0.2.0.dist-info/entry_points.txt +3 -0
- blablador-0.2.0.dist-info/licenses/LICENSE +21 -0
blablador/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Python client for JSC's Blablador LLM API."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from ._client import API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, AsyncBlablador, Blablador
|
|
6
|
+
from ._errors import RateLimitError
|
|
7
|
+
from ._models import BlabladorModel
|
|
8
|
+
from ._reasoning import reasoning
|
|
9
|
+
from ._rerank import RerankResponse, RerankResult, RerankUsage
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("blablador")
|
|
13
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
14
|
+
__version__ = "0+unknown"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"API_KEY_ENV",
|
|
18
|
+
"BASE_URL_ENV",
|
|
19
|
+
"DEFAULT_BASE_URL",
|
|
20
|
+
"AsyncBlablador",
|
|
21
|
+
"Blablador",
|
|
22
|
+
"BlabladorModel",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"RerankResponse",
|
|
25
|
+
"RerankResult",
|
|
26
|
+
"RerankUsage",
|
|
27
|
+
"__version__",
|
|
28
|
+
"reasoning",
|
|
29
|
+
]
|
blablador/__main__.py
ADDED
blablador/_client.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Preconfigured OpenAI SDK clients for Blablador."""
|
|
2
|
+
|
|
3
|
+
# TASK(20261031-134631): preconfigured Blablador / AsyncBlablador clients
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from functools import cached_property
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
import httpx2
|
|
15
|
+
import openai
|
|
16
|
+
from openai import DEFAULT_MAX_RETRIES, APIStatusError, AuthenticationError, NotGiven, not_given
|
|
17
|
+
from openai._base_client import _RequestBodyReplay
|
|
18
|
+
from openai._models import FinalRequestOptions
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
21
|
+
from ._errors import RateLimitError
|
|
22
|
+
from ._models import AsyncBlabladorModels, BlabladorModels
|
|
23
|
+
from ._rerank import AsyncRerank, Rerank
|
|
24
|
+
from ._retry import DEFAULT_MAX_WAIT, RateLimitGate, RateLimitPolicy
|
|
25
|
+
from ._tools import prepare_tool_descriptions
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.blablador.fz-juelich.de/v1"
|
|
28
|
+
API_KEY_ENV = "BLABLADOR_API_KEY"
|
|
29
|
+
BASE_URL_ENV = "BLABLADOR_BASE_URL"
|
|
30
|
+
TOKEN_URL = "https://codebase.helmholtz.cloud/-/user_settings/personal_access_tokens"
|
|
31
|
+
|
|
32
|
+
_KEY_HELP = (
|
|
33
|
+
f"Create a token with the `read_user` scope at {TOKEN_URL} "
|
|
34
|
+
f"and pass it as `api_key` or set {API_KEY_ENV}."
|
|
35
|
+
)
|
|
36
|
+
_AUTH_HINT = (
|
|
37
|
+
"Blablador rejected the API key. Helmholtz Codebase tokens are valid for at most "
|
|
38
|
+
f"one year, so it may have expired. {_KEY_HELP}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve(api_key: Any, base_url: Any) -> tuple[Any, Any]:
|
|
43
|
+
"""Fill in the API key and base URL from BLABLADOR_* variables; explicit arguments win."""
|
|
44
|
+
if api_key is None:
|
|
45
|
+
api_key = os.environ.get(API_KEY_ENV)
|
|
46
|
+
if not api_key:
|
|
47
|
+
raise openai.OpenAIError(f"Missing Blablador API key. {_KEY_HELP}")
|
|
48
|
+
if base_url is None or isinstance(base_url, NotGiven):
|
|
49
|
+
base_url = os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL
|
|
50
|
+
return api_key, base_url
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _BlabladorMixin:
|
|
54
|
+
"""Behaviour shared by the sync and async clients.
|
|
55
|
+
|
|
56
|
+
The OpenAI constructor reads OPENAI_* variables for anything not passed explicitly
|
|
57
|
+
(organization, project, admin key, extra headers). Those belong to OpenAI, not to
|
|
58
|
+
Blablador, so `_drop_openai_env` undoes them after construction: an OpenAI setup in
|
|
59
|
+
the user's shell must not send its headers or credentials to Blablador. `copy()` /
|
|
60
|
+
`with_options()` build new instances through `__init__`, so they get the same treatment.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
organization: str | None
|
|
64
|
+
project: str | None
|
|
65
|
+
admin_api_key: str | None
|
|
66
|
+
webhook_secret: str | None
|
|
67
|
+
_custom_headers: Mapping[str, str]
|
|
68
|
+
_ambient_authorizations: frozenset[str]
|
|
69
|
+
_rate_limit: RateLimitPolicy
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def rate_limit_retries(self) -> int:
|
|
73
|
+
"""How often a request is retried after a 429 that states how long to wait."""
|
|
74
|
+
return self._rate_limit.retries
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def max_rate_limit_wait(self) -> float:
|
|
78
|
+
"""The longest stated wait, in seconds, that is waited out rather than raised."""
|
|
79
|
+
return self._rate_limit.max_wait
|
|
80
|
+
|
|
81
|
+
def copy(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
rate_limit_retries: int | None = None,
|
|
85
|
+
max_rate_limit_wait: float | None = None,
|
|
86
|
+
_extra_kwargs: Mapping[str, Any] = {},
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> Self:
|
|
89
|
+
"""`openai.OpenAI.copy`, plus the rate-limit settings. A copy that keeps the API key
|
|
90
|
+
and base URL also shares the record of the key's current block."""
|
|
91
|
+
base_url = kwargs.get("base_url")
|
|
92
|
+
same_key = kwargs.get("api_key") is None and (
|
|
93
|
+
base_url is None or isinstance(base_url, NotGiven)
|
|
94
|
+
)
|
|
95
|
+
rate_limit = {
|
|
96
|
+
"rate_limit_retries": (
|
|
97
|
+
self.rate_limit_retries if rate_limit_retries is None else rate_limit_retries
|
|
98
|
+
),
|
|
99
|
+
"max_rate_limit_wait": (
|
|
100
|
+
self.max_rate_limit_wait if max_rate_limit_wait is None else max_rate_limit_wait
|
|
101
|
+
),
|
|
102
|
+
"_rate_limit_gate": self._rate_limit.gate if same_key else None,
|
|
103
|
+
}
|
|
104
|
+
return super().copy( # type: ignore[misc]
|
|
105
|
+
_extra_kwargs={**rate_limit, **_extra_kwargs}, **kwargs
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
with_options = copy
|
|
109
|
+
|
|
110
|
+
def _drop_openai_env(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
organization: str | None,
|
|
114
|
+
project: str | None,
|
|
115
|
+
admin_api_key: str | None,
|
|
116
|
+
webhook_secret: str | None,
|
|
117
|
+
default_headers: Mapping[str, str] | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.organization = organization
|
|
120
|
+
self.project = project
|
|
121
|
+
self.admin_api_key = admin_api_key
|
|
122
|
+
self.webhook_secret = webhook_secret
|
|
123
|
+
# OPENAI_CUSTOM_HEADERS is merged into the custom headers; keep only explicit ones.
|
|
124
|
+
self._custom_headers = dict(default_headers or {})
|
|
125
|
+
self._ambient_authorizations = frozenset()
|
|
126
|
+
|
|
127
|
+
def _make_status_error(
|
|
128
|
+
self, err_msg: str, *, body: object, response: httpx2.Response
|
|
129
|
+
) -> APIStatusError:
|
|
130
|
+
err: APIStatusError = super()._make_status_error( # type: ignore[misc]
|
|
131
|
+
err_msg, body=body, response=response
|
|
132
|
+
)
|
|
133
|
+
if isinstance(err, AuthenticationError):
|
|
134
|
+
return AuthenticationError(f"{err_msg}\n{_AUTH_HINT}", response=response, body=err.body)
|
|
135
|
+
if isinstance(err, openai.RateLimitError):
|
|
136
|
+
return RateLimitError(err_msg, response=response, body=err.body)
|
|
137
|
+
return err
|
|
138
|
+
|
|
139
|
+
def _should_retry(self, response: httpx2.Response) -> bool:
|
|
140
|
+
# TASK(20261031-134636): never retry 429. Blablador blocks a key for 60 s and doubles
|
|
141
|
+
# the block with each further violation, and the SDK's quick retries (or a short
|
|
142
|
+
# Retry-After) could count as violations. Timeouts, 409 and 5xx still retry. The
|
|
143
|
+
# opt-in `rate_limit_retries` waits out a stated block in `request` instead.
|
|
144
|
+
if response.status_code == 429:
|
|
145
|
+
return False
|
|
146
|
+
return super()._should_retry(response) # type: ignore[misc]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Blablador(_BlabladorMixin, openai.OpenAI):
|
|
150
|
+
"""`openai.OpenAI` preconfigured for Blablador.
|
|
151
|
+
|
|
152
|
+
The API key comes from `api_key` or BLABLADOR_API_KEY, the base URL from `base_url`,
|
|
153
|
+
BLABLADOR_BASE_URL or the public endpoint. OPENAI_* variables are ignored. Function
|
|
154
|
+
tools without a description get `description: ""`, which the gateway requires.
|
|
155
|
+
|
|
156
|
+
429 responses raise `blablador.RateLimitError` and are not retried, unless
|
|
157
|
+
`rate_limit_retries` is set: then a request waits out a 429 that states how long to wait
|
|
158
|
+
(and at most `max_rate_limit_wait` seconds) and is sent again, up to that many times. While
|
|
159
|
+
the key is known to be blocked, other requests through this client and its copies with
|
|
160
|
+
`rate_limit_retries` set wait too, or raise if the wait would be longer than
|
|
161
|
+
`max_rate_limit_wait`. All other arguments are those of `openai.OpenAI`.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
api_key: str | Callable[[], str] | None = None,
|
|
168
|
+
base_url: str | httpx2.URL | None = None,
|
|
169
|
+
organization: str | None = None,
|
|
170
|
+
project: str | None = None,
|
|
171
|
+
admin_api_key: str | None = None,
|
|
172
|
+
webhook_secret: str | None = None,
|
|
173
|
+
timeout: float | httpx2.Timeout | None | NotGiven = not_given,
|
|
174
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
175
|
+
default_headers: Mapping[str, str] | None = None,
|
|
176
|
+
default_query: Mapping[str, object] | None = None,
|
|
177
|
+
http_client: httpx2.Client | None = None,
|
|
178
|
+
rate_limit_retries: int = 0,
|
|
179
|
+
max_rate_limit_wait: float = DEFAULT_MAX_WAIT,
|
|
180
|
+
_rate_limit_gate: RateLimitGate | None = None,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> None:
|
|
183
|
+
api_key, base_url = _resolve(api_key, base_url)
|
|
184
|
+
self._rate_limit = RateLimitPolicy(
|
|
185
|
+
rate_limit_retries, max_rate_limit_wait, _rate_limit_gate
|
|
186
|
+
)
|
|
187
|
+
super().__init__(
|
|
188
|
+
api_key=api_key,
|
|
189
|
+
base_url=base_url,
|
|
190
|
+
organization=organization,
|
|
191
|
+
project=project,
|
|
192
|
+
admin_api_key=admin_api_key,
|
|
193
|
+
webhook_secret=webhook_secret,
|
|
194
|
+
timeout=timeout,
|
|
195
|
+
max_retries=max_retries,
|
|
196
|
+
default_headers=default_headers,
|
|
197
|
+
default_query=default_query,
|
|
198
|
+
http_client=http_client,
|
|
199
|
+
**kwargs,
|
|
200
|
+
)
|
|
201
|
+
self._drop_openai_env(
|
|
202
|
+
organization=organization,
|
|
203
|
+
project=project,
|
|
204
|
+
admin_api_key=admin_api_key,
|
|
205
|
+
webhook_secret=webhook_secret,
|
|
206
|
+
default_headers=default_headers,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
|
|
210
|
+
return super()._prepare_options(prepare_tool_descriptions(options))
|
|
211
|
+
|
|
212
|
+
def request( # type: ignore[override]
|
|
213
|
+
self, cast_to: Any, options: FinalRequestOptions, *, stream: bool = False, **kwargs: Any
|
|
214
|
+
) -> Any:
|
|
215
|
+
if not self.rate_limit_retries:
|
|
216
|
+
return super().request(cast_to, options, stream=stream, **kwargs)
|
|
217
|
+
body = _RequestBodyReplay(options)
|
|
218
|
+
retries_taken = 0
|
|
219
|
+
while True:
|
|
220
|
+
# Re-check after sleeping: another thread may have recorded a longer block.
|
|
221
|
+
while (wait := self._rate_limit.wait_before_send()) > 0:
|
|
222
|
+
time.sleep(wait)
|
|
223
|
+
try:
|
|
224
|
+
return super().request(cast_to, options, stream=stream, **kwargs)
|
|
225
|
+
except RateLimitError as err:
|
|
226
|
+
replayable = body.rewind()
|
|
227
|
+
if (
|
|
228
|
+
self._rate_limit.wait_before_retry(err, retries_taken, replayable=replayable)
|
|
229
|
+
is None
|
|
230
|
+
):
|
|
231
|
+
raise
|
|
232
|
+
retries_taken += 1
|
|
233
|
+
|
|
234
|
+
@cached_property
|
|
235
|
+
def models(self) -> BlabladorModels:
|
|
236
|
+
"""`list()` returns `BlabladorModel`s with Blablador's fields; `aliases()` maps
|
|
237
|
+
alias names to models. `retrieve()` is not supported by Blablador (404)."""
|
|
238
|
+
return BlabladorModels(self)
|
|
239
|
+
|
|
240
|
+
@cached_property
|
|
241
|
+
def rerank(self) -> Rerank:
|
|
242
|
+
"""Order documents by relevance to a query (`/v1/rerank`, not in the OpenAI API)."""
|
|
243
|
+
return Rerank(self)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class AsyncBlablador(_BlabladorMixin, openai.AsyncOpenAI):
|
|
247
|
+
"""`openai.AsyncOpenAI` preconfigured for Blablador; configured like `Blablador`."""
|
|
248
|
+
|
|
249
|
+
def __init__(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
api_key: str | Callable[[], Awaitable[str]] | None = None,
|
|
253
|
+
base_url: str | httpx2.URL | None = None,
|
|
254
|
+
organization: str | None = None,
|
|
255
|
+
project: str | None = None,
|
|
256
|
+
admin_api_key: str | None = None,
|
|
257
|
+
webhook_secret: str | None = None,
|
|
258
|
+
timeout: float | httpx2.Timeout | None | NotGiven = not_given,
|
|
259
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
260
|
+
default_headers: Mapping[str, str] | None = None,
|
|
261
|
+
default_query: Mapping[str, object] | None = None,
|
|
262
|
+
http_client: httpx2.AsyncClient | None = None,
|
|
263
|
+
rate_limit_retries: int = 0,
|
|
264
|
+
max_rate_limit_wait: float = DEFAULT_MAX_WAIT,
|
|
265
|
+
_rate_limit_gate: RateLimitGate | None = None,
|
|
266
|
+
**kwargs: Any,
|
|
267
|
+
) -> None:
|
|
268
|
+
api_key, base_url = _resolve(api_key, base_url)
|
|
269
|
+
self._rate_limit = RateLimitPolicy(
|
|
270
|
+
rate_limit_retries, max_rate_limit_wait, _rate_limit_gate
|
|
271
|
+
)
|
|
272
|
+
super().__init__(
|
|
273
|
+
api_key=api_key,
|
|
274
|
+
base_url=base_url,
|
|
275
|
+
organization=organization,
|
|
276
|
+
project=project,
|
|
277
|
+
admin_api_key=admin_api_key,
|
|
278
|
+
webhook_secret=webhook_secret,
|
|
279
|
+
timeout=timeout,
|
|
280
|
+
max_retries=max_retries,
|
|
281
|
+
default_headers=default_headers,
|
|
282
|
+
default_query=default_query,
|
|
283
|
+
http_client=http_client,
|
|
284
|
+
**kwargs,
|
|
285
|
+
)
|
|
286
|
+
self._drop_openai_env(
|
|
287
|
+
organization=organization,
|
|
288
|
+
project=project,
|
|
289
|
+
admin_api_key=admin_api_key,
|
|
290
|
+
webhook_secret=webhook_secret,
|
|
291
|
+
default_headers=default_headers,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
|
|
295
|
+
return await super()._prepare_options(prepare_tool_descriptions(options))
|
|
296
|
+
|
|
297
|
+
async def request( # type: ignore[override]
|
|
298
|
+
self, cast_to: Any, options: FinalRequestOptions, *, stream: bool = False, **kwargs: Any
|
|
299
|
+
) -> Any:
|
|
300
|
+
if not self.rate_limit_retries:
|
|
301
|
+
return await super().request(cast_to, options, stream=stream, **kwargs)
|
|
302
|
+
body = _RequestBodyReplay(options)
|
|
303
|
+
retries_taken = 0
|
|
304
|
+
while True:
|
|
305
|
+
while (wait := self._rate_limit.wait_before_send()) > 0:
|
|
306
|
+
await anyio.sleep(wait)
|
|
307
|
+
try:
|
|
308
|
+
return await super().request(cast_to, options, stream=stream, **kwargs)
|
|
309
|
+
except RateLimitError as err:
|
|
310
|
+
replayable = body.rewind()
|
|
311
|
+
if (
|
|
312
|
+
self._rate_limit.wait_before_retry(err, retries_taken, replayable=replayable)
|
|
313
|
+
is None
|
|
314
|
+
):
|
|
315
|
+
raise
|
|
316
|
+
retries_taken += 1
|
|
317
|
+
|
|
318
|
+
@cached_property
|
|
319
|
+
def models(self) -> AsyncBlabladorModels:
|
|
320
|
+
"""See `Blablador.models`."""
|
|
321
|
+
return AsyncBlabladorModels(self)
|
|
322
|
+
|
|
323
|
+
@cached_property
|
|
324
|
+
def rerank(self) -> AsyncRerank:
|
|
325
|
+
"""See `Blablador.rerank`."""
|
|
326
|
+
return AsyncRerank(self)
|
blablador/_errors.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Blablador-specific errors."""
|
|
2
|
+
|
|
3
|
+
# TASK(20261031-134636): rate-limit-safe retries
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import email.utils
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
import openai
|
|
14
|
+
|
|
15
|
+
RATE_LIMIT_HINT = (
|
|
16
|
+
"Blablador blocks a key that exceeds its rate limit: 60 s for the first violation, doubling "
|
|
17
|
+
"with each further one up to 1 h. The client does not retry 429 responses unless "
|
|
18
|
+
"`rate_limit_retries` is set, since a retry may extend the block; wait before sending more "
|
|
19
|
+
"requests."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_UNIT_SECONDS = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0}
|
|
23
|
+
# A number with a unit, after a phrase that says it is a wait ("try again in 57 seconds",
|
|
24
|
+
# "remaining time: 42s", "blocked for 2 minutes"). Bare "in" or "for" are not enough: they
|
|
25
|
+
# also introduce the limit's window ("100 requests in 1 hour", "requests for user 12345 in
|
|
26
|
+
# the last 60 seconds"), which is not how long to wait. Longer unit spellings come first.
|
|
27
|
+
_WAIT_IN_TEXT = re.compile(
|
|
28
|
+
r"\b(?:(?:try\s+again|retry|resets?)\s+(?:in|after)|retry-after|wait(?:\s+for)?"
|
|
29
|
+
r"|remaining|blocked\s+for)\b\D{0,30}?"
|
|
30
|
+
r"(\d+(?:\.\d+)?)\s*"
|
|
31
|
+
r"(milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)\b",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_retry_after(headers: httpx2.Headers) -> float | None:
|
|
37
|
+
"""Seconds to wait according to `retry-after-ms` or `retry-after`, or None if not given."""
|
|
38
|
+
for name, divisor in (("retry-after-ms", 1000), ("retry-after", 1)):
|
|
39
|
+
value = headers.get(name)
|
|
40
|
+
if value is None:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
seconds = float(value) / divisor
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
# "nan" or "inf" would pass every comparison or never end; treat them as not given.
|
|
47
|
+
if math.isfinite(seconds):
|
|
48
|
+
return max(seconds, 0.0)
|
|
49
|
+
date = email.utils.parsedate_tz(headers.get("retry-after") or "")
|
|
50
|
+
if date is None:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return max(email.utils.mktime_tz(date) - time.time(), 0.0)
|
|
54
|
+
except (OverflowError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_wait_in_text(text: str) -> float | None:
|
|
59
|
+
"""Seconds to wait stated in an error message, or None. With several, the longest wins:
|
|
60
|
+
waiting too long costs time, waiting too little may extend the block."""
|
|
61
|
+
waits = []
|
|
62
|
+
for number, unit in _WAIT_IN_TEXT.findall(text):
|
|
63
|
+
unit = unit.lower()
|
|
64
|
+
key = "ms" if unit.startswith("milli") or unit == "ms" else unit[0]
|
|
65
|
+
waits.append(float(number) * _UNIT_SECONDS[key])
|
|
66
|
+
return max(waits) if waits else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _response_text(response: httpx2.Response) -> str:
|
|
70
|
+
try:
|
|
71
|
+
return response.text
|
|
72
|
+
except httpx2.ResponseNotRead:
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RateLimitError(openai.RateLimitError):
|
|
77
|
+
"""HTTP 429 from Blablador. Also an `openai.RateLimitError`.
|
|
78
|
+
|
|
79
|
+
`retry_after` is the wait in seconds the server asked for, from a `Retry-After` /
|
|
80
|
+
`retry-after-ms` header or else from the message text, or None if it did not say.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
retry_after: float | None
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
message: str,
|
|
88
|
+
*,
|
|
89
|
+
response: httpx2.Response,
|
|
90
|
+
body: object | None,
|
|
91
|
+
retry_after: float | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
# `retry_after` is passed when the client raises a block it already knows about
|
|
94
|
+
# (the remaining time) instead of sending the request.
|
|
95
|
+
self.server_message = message
|
|
96
|
+
if retry_after is None:
|
|
97
|
+
retry_after = parse_retry_after(response.headers)
|
|
98
|
+
if retry_after is None:
|
|
99
|
+
retry_after = parse_wait_in_text(_response_text(response))
|
|
100
|
+
self.retry_after = retry_after
|
|
101
|
+
hint = RATE_LIMIT_HINT
|
|
102
|
+
if self.retry_after is not None:
|
|
103
|
+
hint += f" The server asks to wait {self.retry_after:.0f} s."
|
|
104
|
+
super().__init__(f"{message}\n{hint}", response=response, body=body)
|
blablador/_models.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Model listing with Blablador's extra fields.
|
|
2
|
+
|
|
3
|
+
TASK(20261031-134634): `GET /v1/models` entries carry fields the OpenAI API does not have
|
|
4
|
+
(`root`, `max_model_len`, `client_count`, ...), which the SDK keeps only in `model_extra`.
|
|
5
|
+
`client.models.list()` returns `BlabladorModel`s that declare them, and
|
|
6
|
+
`client.models.aliases()` maps each `alias-*` name to the model behind it.
|
|
7
|
+
|
|
8
|
+
Observed 2026-10-31 (63 entries): `root` is a Hugging Face name, an absolute path on the
|
|
9
|
+
server, or null (7 entries); `max_model_len` is missing on 10 (image, audio, anonymizer);
|
|
10
|
+
`parent` is always null. `GET /v1/models/{id}` answers 404, so `retrieve()` does not work.
|
|
11
|
+
`capabilities` appears on only 2 entries, in two different shapes, so what a model can do
|
|
12
|
+
(chat, embeddings, rerank, ...) cannot be read from the listing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import httpx2
|
|
18
|
+
from openai._base_client import AsyncPaginator, make_request_options
|
|
19
|
+
from openai._types import Body, Headers, NotGiven, Query, not_given
|
|
20
|
+
from openai.pagination import AsyncPage, SyncPage
|
|
21
|
+
from openai.resources.models import AsyncModels, Models
|
|
22
|
+
from openai.types import Model
|
|
23
|
+
|
|
24
|
+
ALIAS_PREFIX = "alias-"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BlabladorModel(Model):
|
|
28
|
+
"""A model as Blablador lists it: `openai.types.Model` plus the gateway's fields.
|
|
29
|
+
|
|
30
|
+
Fields the gateway adds beyond these (`router_instances`, `backend_instances`,
|
|
31
|
+
`permission`, ...) stay available in `model_extra`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
root: str | None = None
|
|
35
|
+
"""The underlying model: a Hugging Face name such as `openai/gpt-oss-120b`, a path on
|
|
36
|
+
the server's filesystem, or None. Aliases and display names of one model share it."""
|
|
37
|
+
|
|
38
|
+
parent: str | None = None
|
|
39
|
+
|
|
40
|
+
max_model_len: int | None = None
|
|
41
|
+
"""Context length in tokens, if the backend reports one."""
|
|
42
|
+
|
|
43
|
+
client_count: int | None = None
|
|
44
|
+
"""Number of backend servers currently serving the model, if reported."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_alias(self) -> bool:
|
|
48
|
+
"""True for the `alias-*` names, which keep pointing at a current model."""
|
|
49
|
+
return self.id.startswith(ALIAS_PREFIX)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BlabladorModels(Models):
|
|
53
|
+
"""`client.models` with typed Blablador fields and `aliases()`."""
|
|
54
|
+
|
|
55
|
+
def list( # pyright: ignore[reportIncompatibleMethodOverride]
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
extra_headers: Headers | None = None,
|
|
59
|
+
extra_query: Query | None = None,
|
|
60
|
+
extra_body: Body | None = None,
|
|
61
|
+
timeout: float | httpx2.Timeout | None | NotGiven = not_given,
|
|
62
|
+
) -> SyncPage[BlabladorModel]:
|
|
63
|
+
"""List the available models, including the `alias-*` names."""
|
|
64
|
+
return self._get_api_list(
|
|
65
|
+
"/models",
|
|
66
|
+
page=SyncPage[BlabladorModel],
|
|
67
|
+
options=make_request_options(
|
|
68
|
+
extra_headers=extra_headers,
|
|
69
|
+
extra_query=extra_query,
|
|
70
|
+
extra_body=extra_body,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
security={"bearer_auth": True},
|
|
73
|
+
),
|
|
74
|
+
model=BlabladorModel,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def aliases(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
extra_headers: Headers | None = None,
|
|
81
|
+
extra_query: Query | None = None,
|
|
82
|
+
timeout: float | httpx2.Timeout | None | NotGiven = not_given,
|
|
83
|
+
) -> dict[str, str | None]:
|
|
84
|
+
"""Map each `alias-*` name to the `root` of the model behind it right now (one request).
|
|
85
|
+
|
|
86
|
+
Aliases are re-pointed as models are replaced, so read this at runtime rather than
|
|
87
|
+
hardcoding it. The value is None for models whose backend reports no root.
|
|
88
|
+
"""
|
|
89
|
+
models = self.list(extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
|
|
90
|
+
return {model.id: model.root for model in models if model.is_alias}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AsyncBlabladorModels(AsyncModels):
|
|
94
|
+
"""`client.models` of `AsyncBlablador`; see `BlabladorModels`."""
|
|
95
|
+
|
|
96
|
+
def list( # pyright: ignore[reportIncompatibleMethodOverride]
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
extra_headers: Headers | None = None,
|
|
100
|
+
extra_query: Query | None = None,
|
|
101
|
+
extra_body: Body | None = None,
|
|
102
|
+
timeout: float | httpx2.Timeout | None | NotGiven = not_given,
|
|
103
|
+
) -> AsyncPaginator[BlabladorModel, AsyncPage[BlabladorModel]]:
|
|
104
|
+
"""List the available models, including the `alias-*` names."""
|
|
105
|
+
return self._get_api_list(
|
|
106
|
+
"/models",
|
|
107
|
+
page=AsyncPage[BlabladorModel],
|
|
108
|
+
options=make_request_options(
|
|
109
|
+
extra_headers=extra_headers,
|
|
110
|
+
extra_query=extra_query,
|
|
111
|
+
extra_body=extra_body,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
security={"bearer_auth": True},
|
|
114
|
+
),
|
|
115
|
+
model=BlabladorModel,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def aliases(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
extra_headers: Headers | None = None,
|
|
122
|
+
extra_query: Query | None = None,
|
|
123
|
+
timeout: float | httpx2.Timeout | None | NotGiven = not_given,
|
|
124
|
+
) -> dict[str, str | None]:
|
|
125
|
+
"""Map each `alias-*` name to the `root` of the model behind it right now (one request)."""
|
|
126
|
+
models = self.list(extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
|
|
127
|
+
return {model.id: model.root async for model in models if model.is_alias}
|
blablador/_reasoning.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Typed access to the reasoning text of chat completions.
|
|
2
|
+
|
|
3
|
+
TASK(20261031-134633): reasoning models on Blablador return their reasoning next to the
|
|
4
|
+
answer, in a field the OpenAI API does not define, so the SDK keeps it only in
|
|
5
|
+
`model_extra`. The name depends on the backend (checked 2026-10-31): vLLM sends
|
|
6
|
+
`reasoning` (alias-fast, alias-code, alias-deepseek-v41-flash-booster), sglang sends
|
|
7
|
+
`reasoning_content` (alias-mistral-large).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from openai.types.chat import ChatCompletionMessage
|
|
13
|
+
from openai.types.chat.chat_completion_chunk import ChoiceDelta
|
|
14
|
+
|
|
15
|
+
REASONING_FIELDS = ("reasoning", "reasoning_content")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reasoning(message: ChatCompletionMessage | ChoiceDelta) -> str | None:
|
|
19
|
+
"""The reasoning text of a chat message or a streamed delta, or None if it has none.
|
|
20
|
+
|
|
21
|
+
completion = client.chat.completions.create(...)
|
|
22
|
+
print(blablador.reasoning(completion.choices[0].message))
|
|
23
|
+
|
|
24
|
+
for chunk in client.chat.completions.create(..., stream=True):
|
|
25
|
+
if chunk.choices:
|
|
26
|
+
print(blablador.reasoning(chunk.choices[0].delta) or "", end="")
|
|
27
|
+
"""
|
|
28
|
+
for name in REASONING_FIELDS:
|
|
29
|
+
# getattr finds the field in `model_extra` and also once a later SDK declares it.
|
|
30
|
+
value = getattr(message, name, None)
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
return value
|
|
33
|
+
return None
|