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.
- dot_inference/README.md +83 -0
- dot_inference/__init__.py +86 -0
- dot_inference/_client.py +520 -0
- dot_inference/_cost_tracker.py +71 -0
- dot_inference/_llm_registry.py +302 -0
- dot_inference/_local_check.py +76 -0
- dot_inference/_mistral_compat.py +530 -0
- dot_inference/_openai_compat.py +570 -0
- dot_inference/_rate_limit.py +64 -0
- dot_inference/_retry.py +66 -0
- dot_inference/_sync.py +49 -0
- dot_inference/_types.py +229 -0
- dot_inference/mock.py +151 -0
- dot_inference/models.yaml +481 -0
- dot_inference/settings.py +138 -0
- dot_inference-2.0.2.dist-info/METADATA +351 -0
- dot_inference-2.0.2.dist-info/RECORD +19 -0
- dot_inference-2.0.2.dist-info/WHEEL +4 -0
- dot_inference-2.0.2.dist-info/licenses/LICENSE.md +660 -0
|
@@ -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
|
dot_inference/_retry.py
ADDED
|
@@ -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))
|