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 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
@@ -0,0 +1,7 @@
1
+ """`python -m blablador` runs the command-line tool."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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}
@@ -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