dot-inference 2.0.2__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.
@@ -0,0 +1,570 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Shared OpenAI-compatible client.
5
+
6
+ Provides ``OpenAICompatClient`` — a class-based LLM client for any
7
+ OpenAI-compatible endpoint (OpenAI, OpenRouter, Azure, local servers).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import inspect
14
+ import logging
15
+ from collections.abc import AsyncGenerator
16
+ from typing import Any
17
+
18
+ import openai
19
+ from openai import AsyncOpenAI
20
+ from pydantic import BaseModel
21
+
22
+ from dot_inference._client import EmbeddingClient, LLMClient
23
+ from dot_inference._cost_tracker import compute_cost
24
+ from dot_inference._llm_registry import ResolvedModelConfig
25
+ from dot_inference._retry import compute_wait
26
+ from dot_inference._types import (
27
+ InputJsonDelta,
28
+ LLMResponse,
29
+ Message,
30
+ ReasoningDelta,
31
+ StreamDone,
32
+ StreamEvent,
33
+ StreamUsage,
34
+ TextDelta,
35
+ ToolCall,
36
+ ToolCallDone,
37
+ ToolDef,
38
+ )
39
+
40
+ LOGGER = logging.getLogger(__name__)
41
+ # TODO: include the deepika logger
42
+
43
+ _MAX_ATTEMPTS = 4
44
+ _RETRY_BASE_WAIT = 3 # seconds
45
+
46
+ # Client-side errors that should NOT be retried.
47
+ _NON_RETRYABLE = (
48
+ openai.BadRequestError,
49
+ openai.AuthenticationError,
50
+ openai.PermissionDeniedError,
51
+ openai.NotFoundError,
52
+ openai.UnprocessableEntityError,
53
+ )
54
+
55
+
56
+ def _tools_to_openai(tools: list[ToolDef]) -> list[dict[str, Any]]:
57
+ """Convert ToolDef list to OpenAI tools format."""
58
+ return [
59
+ {
60
+ "type": "function",
61
+ "function": {
62
+ "name": t.name,
63
+ "description": t.description,
64
+ "parameters": t.parameters,
65
+ },
66
+ }
67
+ for t in tools
68
+ ]
69
+
70
+
71
+ def _extract_reasoning(delta: Any) -> str | None:
72
+ """Pull a reasoning/thinking fragment out of a chat-completion delta.
73
+
74
+ Reasoning is not part of the official Chat Completions schema, so
75
+ providers extend it in different ways: vLLM/DeepSeek-style servers
76
+ use ``delta.reasoning_content``, OpenRouter normalizes it to
77
+ ``delta.reasoning``. OpenAI itself returns neither — its reasoning
78
+ stays hidden (summaries exist only on the Responses API).
79
+ Returns None when the chunk carries no reasoning.
80
+ """
81
+ for attr in ("reasoning_content", "reasoning"):
82
+ value = getattr(delta, attr, None)
83
+ if isinstance(value, str) and value:
84
+ return value
85
+ return None
86
+
87
+
88
+ def _extract_cached_tokens(usage: Any) -> int | None:
89
+ """Pull cached-token count out of a usage object if present.
90
+
91
+ OpenAI / OpenRouter expose it as ``usage.prompt_tokens_details.cached_tokens``.
92
+ Returns None if the provider doesn't report it.
93
+ """
94
+ if usage is None:
95
+ return None
96
+ details = getattr(usage, "prompt_tokens_details", None)
97
+ if details is None:
98
+ return None
99
+ return getattr(details, "cached_tokens", None)
100
+
101
+
102
+ class OpenAICompatClient(LLMClient):
103
+ """LLM client for any OpenAI-compatible endpoint.
104
+
105
+ The underlying ``AsyncOpenAI`` SDK client is stored as ``self._client``
106
+ and created once at construction time.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ model: str,
112
+ base_url: str,
113
+ api_key: str | None = None,
114
+ model_config: ResolvedModelConfig | None = None,
115
+ client: AsyncOpenAI | None = None,
116
+ temperature: float | None = None,
117
+ reasoning_effort: str | None = None,
118
+ max_tokens: int | None = None,
119
+ timeout: float = 300,
120
+ ) -> None:
121
+ if client is None:
122
+ client_kwargs: dict[str, Any] = {
123
+ "base_url": base_url,
124
+ "timeout": timeout,
125
+ }
126
+ if api_key is not None:
127
+ client_kwargs["api_key"] = api_key
128
+ client = AsyncOpenAI(**client_kwargs)
129
+
130
+ super().__init__()
131
+ self._client = client
132
+ self._model = model
133
+ self._model_config = model_config
134
+ self._temperature = temperature
135
+ self._reasoning_effort = reasoning_effort
136
+ self._max_tokens = max_tokens
137
+ self._strict = model_config.supports_strict_json if model_config else True
138
+ self._requires_cache_control = (
139
+ model_config.requires_cache_control if model_config else False
140
+ )
141
+
142
+ # OpenRouter extra_body (provider routing, fallback models)
143
+ self._extra_body: dict[str, Any] = {}
144
+ if model_config:
145
+ if model_config.fallback_models:
146
+ self._extra_body["models"] = model_config.fallback_models
147
+ if model_config.provider_routing:
148
+ self._extra_body["provider"] = model_config.provider_routing
149
+
150
+ def _build_create_kwargs(
151
+ self,
152
+ messages: list[Message],
153
+ response_format: type[BaseModel] | dict[str, Any] | None,
154
+ tools: list[ToolDef] | None,
155
+ temperature: float | None,
156
+ reasoning_effort: str | None,
157
+ tool_choice: str | None = None,
158
+ ) -> tuple[dict[str, Any], type[BaseModel] | None]:
159
+ """Build kwargs for ``chat.completions.create``.
160
+
161
+ Returns ``(create_kwargs, response_model)`` where *response_model* is
162
+ the Pydantic class to validate structured output against (or ``None``).
163
+ """
164
+ create_kwargs: dict[str, Any] = {
165
+ "model": self._model,
166
+ "messages": [m.model_dump(exclude_none=True) for m in messages],
167
+ }
168
+
169
+ # Reasoning models use reasoning_effort instead of temperature
170
+ is_reasoning = self._model_config.supports_reasoning if self._model_config else False
171
+ if is_reasoning and reasoning_effort is not None:
172
+ create_kwargs["reasoning_effort"] = reasoning_effort
173
+ elif not is_reasoning and temperature is not None:
174
+ create_kwargs["temperature"] = temperature
175
+
176
+ response_model: type[BaseModel] | None = None
177
+
178
+ self._validate_params(response_format, tools)
179
+
180
+ # Native tool calling mode
181
+ if tools:
182
+ create_kwargs["tools"] = _tools_to_openai(tools)
183
+ create_kwargs["tool_choice"] = tool_choice if tool_choice is not None else "auto"
184
+
185
+ # Structured JSON output mode
186
+ elif response_format is not None:
187
+ if isinstance(response_format, type) and issubclass(response_format, BaseModel):
188
+ response_model = response_format
189
+ schema = response_format.model_json_schema()
190
+ else:
191
+ schema = response_format
192
+
193
+ # OpenAI strict mode requires additionalProperties: false
194
+ if self._strict and "additionalProperties" not in schema:
195
+ schema["additionalProperties"] = False
196
+
197
+ create_kwargs["response_format"] = {
198
+ "type": "json_schema",
199
+ "json_schema": {
200
+ "name": "Output",
201
+ "strict": self._strict,
202
+ "schema": schema,
203
+ },
204
+ }
205
+
206
+ if self._max_tokens is not None:
207
+ create_kwargs["max_tokens"] = self._max_tokens
208
+
209
+ extra_body = dict(self._extra_body) if self._extra_body else {}
210
+ if self._requires_cache_control:
211
+ # OpenRouter top-level toggle that enables provider caching.
212
+ # Per-block cache_control markers on messages control where
213
+ # breakpoints go; this flag is still required for Haiku/Opus
214
+ # via OpenRouter to actually engage the Anthropic cache.
215
+ extra_body["cache_control"] = {"type": "ephemeral"}
216
+ if extra_body:
217
+ create_kwargs["extra_body"] = extra_body
218
+
219
+ return create_kwargs, response_model
220
+
221
+ async def _create_with_retry(self, create_kwargs: dict[str, Any]) -> Any:
222
+ """Call ``chat.completions.create`` with transient-error retry."""
223
+ for attempt in range(1, _MAX_ATTEMPTS + 1):
224
+ try:
225
+ return await self._client.chat.completions.create(**create_kwargs)
226
+ except openai.APIError as exc:
227
+ if isinstance(exc, _NON_RETRYABLE) or attempt == _MAX_ATTEMPTS:
228
+ raise
229
+ resp = getattr(exc, "response", None)
230
+ retry_after = resp.headers.get("retry-after") if resp is not None else None
231
+ wait = compute_wait(attempt, _RETRY_BASE_WAIT, retry_after)
232
+ LOGGER.warning(
233
+ "Transient API error (attempt %d/%d): %s — retrying in %.1fs",
234
+ attempt,
235
+ _MAX_ATTEMPTS,
236
+ exc,
237
+ wait,
238
+ )
239
+ if self.on_retry is not None:
240
+ self.on_retry(attempt, _MAX_ATTEMPTS, str(exc))
241
+ await asyncio.sleep(wait)
242
+
243
+ async def call(
244
+ self,
245
+ messages: list[Message],
246
+ response_format: type[BaseModel] | dict[str, Any] | None = None,
247
+ tools: list[ToolDef] | None = None,
248
+ *,
249
+ temperature: float | None = None,
250
+ reasoning_effort: str | None = None,
251
+ tool_choice: str | None = None,
252
+ ) -> LLMResponse:
253
+ temperature = self._resolve_temperature(temperature)
254
+ reasoning_effort = self._resolve_reasoning_effort(reasoning_effort)
255
+
256
+ create_kwargs, response_model = self._build_create_kwargs(
257
+ messages,
258
+ response_format,
259
+ tools,
260
+ temperature,
261
+ reasoning_effort,
262
+ tool_choice,
263
+ )
264
+ response = await self._create_with_retry(create_kwargs)
265
+
266
+ prompt_tokens = response.usage.prompt_tokens if response.usage else None
267
+ completion_tokens = response.usage.completion_tokens if response.usage else None
268
+ cached_tokens = _extract_cached_tokens(response.usage)
269
+
270
+ cost = None
271
+ if self._model_config:
272
+ cost = compute_cost(
273
+ response.usage,
274
+ self._model_config.input_token_cost,
275
+ self._model_config.output_token_cost,
276
+ )
277
+ if response.usage:
278
+ LOGGER.debug(
279
+ "tokens: %s in (%s cached) / %s out, cost=$%s",
280
+ prompt_tokens,
281
+ cached_tokens if cached_tokens is not None else "?",
282
+ completion_tokens,
283
+ cost or "?",
284
+ )
285
+
286
+ msg = response.choices[0].message
287
+
288
+ # Parse native tool calls if present
289
+ tool_calls = None
290
+ if msg.tool_calls:
291
+ tool_calls = [
292
+ ToolCall(
293
+ id=tc.id,
294
+ name=tc.function.name,
295
+ arguments=tc.function.arguments,
296
+ )
297
+ for tc in msg.tool_calls
298
+ ]
299
+
300
+ # Parse structured output into Pydantic model if requested
301
+ structured_output = None
302
+ if response_model is not None:
303
+ if not msg.content:
304
+ finish_reason = response.choices[0].finish_reason
305
+ refusal = getattr(msg, "refusal", None)
306
+ raise RuntimeError(
307
+ f"LLM returned empty content with response_format set "
308
+ f"(finish_reason={finish_reason!r}"
309
+ + (f", refusal={refusal!r}" if refusal else "")
310
+ + ")"
311
+ )
312
+ structured_output = response_model.model_validate_json(msg.content)
313
+
314
+ response = LLMResponse(
315
+ content=msg.content,
316
+ tool_calls=tool_calls,
317
+ structured_output=structured_output,
318
+ cost=cost,
319
+ prompt_tokens=prompt_tokens,
320
+ completion_tokens=completion_tokens,
321
+ cached_tokens=cached_tokens,
322
+ )
323
+ self.cost_tracker.record(response)
324
+ return response
325
+
326
+ async def stream(
327
+ self,
328
+ messages: list[Message],
329
+ response_format: type[BaseModel] | dict[str, Any] | None = None,
330
+ tools: list[ToolDef] | None = None,
331
+ *,
332
+ temperature: float | None = None,
333
+ reasoning_effort: str | None = None,
334
+ tool_choice: str | None = None,
335
+ ) -> AsyncGenerator[StreamEvent, None]:
336
+ temperature = self._resolve_temperature(temperature)
337
+ reasoning_effort = self._resolve_reasoning_effort(reasoning_effort)
338
+
339
+ create_kwargs, response_model = self._build_create_kwargs(
340
+ messages,
341
+ response_format,
342
+ tools,
343
+ temperature,
344
+ reasoning_effort,
345
+ tool_choice,
346
+ )
347
+ create_kwargs["stream"] = True
348
+ create_kwargs["stream_options"] = {"include_usage": True}
349
+
350
+ # Accumulators (reset on each retry attempt)
351
+ content_parts: list[str] = []
352
+ tool_acc: dict[int, dict[str, str]] = {}
353
+ finished_tool_calls: list[ToolCall] = []
354
+ prompt_tokens: int | None = None
355
+ completion_tokens: int | None = None
356
+ cached_tokens: int | None = None
357
+ last_usage: Any = None
358
+
359
+ for attempt in range(1, _MAX_ATTEMPTS + 1):
360
+ has_yielded = False
361
+ reasoning_seen = False
362
+ content_parts.clear()
363
+ tool_acc.clear()
364
+ finished_tool_calls.clear()
365
+ prompt_tokens = None
366
+ completion_tokens = None
367
+ cached_tokens = None
368
+ last_usage = None
369
+ response = None
370
+
371
+ try:
372
+ response = await self._client.chat.completions.create(**create_kwargs)
373
+
374
+ async for chunk in response:
375
+ if chunk.usage:
376
+ prompt_tokens = chunk.usage.prompt_tokens
377
+ completion_tokens = chunk.usage.completion_tokens
378
+ cached_tokens = _extract_cached_tokens(chunk.usage)
379
+ last_usage = chunk.usage
380
+
381
+ if not chunk.choices:
382
+ continue
383
+
384
+ choice = chunk.choices[0]
385
+ delta = choice.delta
386
+
387
+ # Reasoning/thinking content (provider extension field)
388
+ reasoning = _extract_reasoning(delta)
389
+ if reasoning:
390
+ if not reasoning_seen:
391
+ # Once per stream: whether a model exposes
392
+ # reasoning is provider-dependent and hard to
393
+ # predict, so make the detection observable.
394
+ reasoning_seen = True
395
+ field = (
396
+ "reasoning_content"
397
+ if getattr(delta, "reasoning_content", None)
398
+ else "reasoning"
399
+ )
400
+ LOGGER.info(
401
+ "model '%s' streams reasoning via delta.%s — "
402
+ "emitting reasoning_delta events",
403
+ self._model,
404
+ field,
405
+ )
406
+ has_yielded = True
407
+ yield ReasoningDelta(content=reasoning)
408
+
409
+ # Text content
410
+ if delta.content:
411
+ content_parts.append(delta.content)
412
+ has_yielded = True
413
+ yield TextDelta(content=delta.content)
414
+
415
+ # Tool call deltas
416
+ if delta.tool_calls:
417
+ for tc_delta in delta.tool_calls:
418
+ idx = tc_delta.index
419
+ if idx not in tool_acc:
420
+ tool_acc[idx] = {"id": "", "name": "", "arguments": ""}
421
+
422
+ acc = tool_acc[idx]
423
+ if tc_delta.id:
424
+ acc["id"] = tc_delta.id
425
+ if tc_delta.function and tc_delta.function.name:
426
+ acc["name"] = tc_delta.function.name
427
+ if tc_delta.function and tc_delta.function.arguments:
428
+ acc["arguments"] += tc_delta.function.arguments
429
+ has_yielded = True
430
+ yield InputJsonDelta(
431
+ tool_call_index=idx,
432
+ tool_name=acc["name"],
433
+ partial_json=tc_delta.function.arguments,
434
+ )
435
+
436
+ # Emit fully assembled tool calls when the model is done
437
+ if choice.finish_reason in ("tool_calls", "stop") and tool_acc:
438
+ for idx in sorted(tool_acc):
439
+ acc = tool_acc[idx]
440
+ tc = ToolCall(
441
+ id=acc["id"], name=acc["name"], arguments=acc["arguments"]
442
+ )
443
+ finished_tool_calls.append(tc)
444
+ has_yielded = True
445
+ yield ToolCallDone(tool_call=tc)
446
+ tool_acc.clear()
447
+
448
+ break # Stream completed successfully
449
+
450
+ except openai.APIError as exc:
451
+ # Can't retry after partial output — consumer already has events
452
+ if isinstance(exc, _NON_RETRYABLE) or has_yielded or attempt == _MAX_ATTEMPTS:
453
+ raise
454
+ resp = getattr(exc, "response", None)
455
+ retry_after = resp.headers.get("retry-after") if resp is not None else None
456
+ wait = compute_wait(attempt, _RETRY_BASE_WAIT, retry_after)
457
+ LOGGER.warning(
458
+ "Transient API error during stream (attempt %d/%d): %s — retrying in %.1fs",
459
+ attempt,
460
+ _MAX_ATTEMPTS,
461
+ exc,
462
+ wait,
463
+ )
464
+ if self.on_retry is not None:
465
+ self.on_retry(attempt, _MAX_ATTEMPTS, str(exc))
466
+ await asyncio.sleep(wait)
467
+
468
+ finally:
469
+ if response is not None:
470
+ aclose = getattr(response, "aclose", None) or getattr(response, "close", None)
471
+ if aclose is not None:
472
+ result = aclose()
473
+ if inspect.isawaitable(result):
474
+ await result
475
+
476
+ # Cost
477
+ cost = None
478
+ if self._model_config:
479
+ cost = compute_cost(
480
+ last_usage,
481
+ self._model_config.input_token_cost,
482
+ self._model_config.output_token_cost,
483
+ )
484
+ if prompt_tokens is not None:
485
+ LOGGER.debug(
486
+ "tokens: %s in (%s cached) / %s out, cost=$%s",
487
+ prompt_tokens,
488
+ cached_tokens if cached_tokens is not None else "?",
489
+ completion_tokens,
490
+ cost or "?",
491
+ )
492
+
493
+ if prompt_tokens is not None or completion_tokens is not None:
494
+ yield StreamUsage(
495
+ prompt_tokens=prompt_tokens,
496
+ completion_tokens=completion_tokens,
497
+ cached_tokens=cached_tokens,
498
+ cost=cost,
499
+ )
500
+
501
+ # Final assembled response
502
+ content = "".join(content_parts) or None
503
+ structured_output = None
504
+ if response_model is not None:
505
+ if not content:
506
+ raise RuntimeError("LLM returned empty streamed content with response_format set")
507
+ structured_output = response_model.model_validate_json(content)
508
+
509
+ final_response = LLMResponse(
510
+ content=content,
511
+ tool_calls=finished_tool_calls or None,
512
+ structured_output=structured_output,
513
+ cost=cost,
514
+ prompt_tokens=prompt_tokens,
515
+ completion_tokens=completion_tokens,
516
+ cached_tokens=cached_tokens,
517
+ )
518
+ self.cost_tracker.record(final_response)
519
+ yield StreamDone(response=final_response)
520
+
521
+
522
+ class OpenAICompatEmbeddingClient(EmbeddingClient):
523
+ """Embedding client for any OpenAI-compatible endpoint.
524
+
525
+ The underlying ``AsyncOpenAI`` SDK client is stored as ``self._client``
526
+ and created once at construction time.
527
+ """
528
+
529
+ def __init__(
530
+ self,
531
+ model: str,
532
+ base_url: str,
533
+ api_key: str | None = None,
534
+ client: AsyncOpenAI | None = None,
535
+ batch_size: int = 0,
536
+ timeout: float = 300,
537
+ ) -> None:
538
+ if client is None:
539
+ client_kwargs: dict[str, Any] = {
540
+ "base_url": base_url,
541
+ "timeout": timeout,
542
+ }
543
+ if api_key is not None:
544
+ client_kwargs["api_key"] = api_key
545
+ client = AsyncOpenAI(**client_kwargs)
546
+
547
+ self._client = client
548
+ self._model = model
549
+ self._batch_size = batch_size
550
+
551
+ async def embed(self, texts: list[str]) -> list[list[float]]:
552
+ """Embed texts. The API natively accepts a list, so batch_size=0
553
+ sends everything in one call. Set batch_size > 0 only to stay
554
+ under provider per-request limits (e.g. OpenAI caps at 2048 inputs)."""
555
+ if not texts:
556
+ return []
557
+
558
+ batches = (
559
+ [texts[i : i + self._batch_size] for i in range(0, len(texts), self._batch_size)]
560
+ if self._batch_size > 0
561
+ else [texts]
562
+ )
563
+
564
+ all_embeddings: list[list[float]] = []
565
+ for batch in batches:
566
+ response = await self._client.embeddings.create(model=self._model, input=batch)
567
+ items = sorted(response.data, key=lambda x: x.index)
568
+ all_embeddings.extend(item.embedding for item in items)
569
+
570
+ return all_embeddings
@@ -0,0 +1,64 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Async rate-limiting wrapper for ``LLMCallable``."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import time
10
+ from collections import deque
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from dot_inference._types import LLMCallable, LLMResponse, Message, ToolDef
16
+
17
+
18
+ def with_rate_limit(
19
+ callable: LLMCallable,
20
+ max_calls_per_minute: int,
21
+ ) -> LLMCallable:
22
+ """Wrap an ``LLMCallable`` with a sliding-window rate limiter.
23
+
24
+ If *max_calls_per_minute* calls have already been made in the last 60
25
+ seconds, the wrapper sleeps until a slot frees up before forwarding
26
+ the call.
27
+
28
+ Returns a new ``LLMCallable`` — the original is not modified.
29
+ """
30
+ if max_calls_per_minute <= 0:
31
+ return callable
32
+
33
+ window = 60.0 # seconds, #TODO: consider placing it in the arguments for flexicility
34
+ timestamps: deque[float] = deque()
35
+ lock = asyncio.Lock()
36
+
37
+ async def _rate_limited_call(
38
+ messages: list[Message],
39
+ response_format: type[BaseModel] | dict[str, Any] | None = None,
40
+ tools: list[ToolDef] | None = None,
41
+ **kwargs: Any,
42
+ ) -> LLMResponse:
43
+ async with lock:
44
+ now = time.monotonic()
45
+
46
+ # Evict timestamps older than the window
47
+ while timestamps and timestamps[0] <= now - window:
48
+ timestamps.popleft()
49
+
50
+ # If at capacity, sleep until the oldest call falls out of the window
51
+ if len(timestamps) >= max_calls_per_minute:
52
+ sleep_for = timestamps[0] - (now - window)
53
+ if sleep_for > 0:
54
+ await asyncio.sleep(sleep_for)
55
+ # Re-evict after sleeping
56
+ now = time.monotonic()
57
+ while timestamps and timestamps[0] <= now - window:
58
+ timestamps.popleft()
59
+
60
+ timestamps.append(time.monotonic())
61
+
62
+ return await callable(messages, response_format, tools, **kwargs)
63
+
64
+ return _rate_limited_call
@@ -0,0 +1,66 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Decides how long to wait before retrying a transient provider error.
5
+
6
+ Shared by the OpenAI and Mistral clients so both honor ``Retry-After`` the
7
+ same way and fall back to the same exponential-backoff schedule when the
8
+ server gives no hint.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import UTC, datetime
14
+ from email.utils import parsedate_to_datetime
15
+
16
+ # Cap on Retry-After to avoid a hostile/misconfigured server stalling us.
17
+ _RETRY_AFTER_MAX = 60.0
18
+
19
+
20
+ def _parse_retry_after(value: str | None) -> float | None:
21
+ """Translate a raw ``Retry-After`` header into seconds-to-wait.
22
+
23
+ The HTTP spec allows two forms, both handled here:
24
+ * delta-seconds — e.g. ``"5"`` or ``"2.5"``
25
+ * HTTP-date — e.g. ``"Wed, 21 Oct 2026 07:28:00 GMT"`` (treated as
26
+ UTC when naïve, per RFC 7231)
27
+
28
+ Return contract (important — callers branch on this):
29
+ * ``None`` — no header, or header unparseable → caller should use
30
+ its own backoff policy.
31
+ * ``0.0`` — header was valid but says "don't wait" (past date, or
32
+ negative delta) → retry immediately.
33
+ * ``float > 0`` — server's hint, in seconds. Caller is responsible for
34
+ any upper-bound clamping.
35
+ """
36
+ if not value:
37
+ return None
38
+ value = value.strip()
39
+ try:
40
+ return max(0.0, float(value))
41
+ except ValueError:
42
+ pass
43
+ try:
44
+ dt = parsedate_to_datetime(value)
45
+ except (TypeError, ValueError):
46
+ return None
47
+ if dt is None:
48
+ return None
49
+ if dt.tzinfo is None:
50
+ dt = dt.replace(tzinfo=UTC)
51
+ return max(0.0, (dt - datetime.now(UTC)).total_seconds())
52
+
53
+
54
+ def compute_wait(attempt: int, base: float, retry_after: str | None = None) -> float:
55
+ """Seconds to sleep before attempt ``attempt + 1``.
56
+
57
+ Policy:
58
+ 1. If the server sent a parseable ``Retry-After``, honor it — clamped
59
+ to ``_RETRY_AFTER_MAX`` so a misconfigured server can't stall us.
60
+ 2. Otherwise, exponential backoff ``base * 2**(attempt - 1)``
61
+ (1st retry = base, 2nd = 2·base, 3rd = 4·base, …).
62
+ """
63
+ hinted = _parse_retry_after(retry_after)
64
+ if hinted is not None:
65
+ return min(hinted, _RETRY_AFTER_MAX)
66
+ return base * (2 ** (attempt - 1))