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
dot_inference/_sync.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Sync helpers — run async code from synchronous contexts.
|
|
5
|
+
|
|
6
|
+
Uses a single persistent background event loop in a daemon thread so that:
|
|
7
|
+
- httpx / SDK async clients can clean up properly (no "Event loop is closed").
|
|
8
|
+
- Multiple sync calls reuse the same loop (no per-call overhead).
|
|
9
|
+
- The daemon thread is cleaned up automatically on process exit.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Coroutine
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_loop: asyncio.AbstractEventLoop | None = None
|
|
20
|
+
_thread: threading.Thread | None = None
|
|
21
|
+
_lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_loop() -> asyncio.AbstractEventLoop:
|
|
25
|
+
"""Return the shared background event loop, creating it on first use."""
|
|
26
|
+
global _loop, _thread
|
|
27
|
+
if _loop is not None and _loop.is_running():
|
|
28
|
+
return _loop
|
|
29
|
+
|
|
30
|
+
with _lock:
|
|
31
|
+
# Double-check under lock.
|
|
32
|
+
if _loop is not None and _loop.is_running():
|
|
33
|
+
return _loop
|
|
34
|
+
|
|
35
|
+
_loop = asyncio.new_event_loop()
|
|
36
|
+
_thread = threading.Thread(target=_loop.run_forever, daemon=True)
|
|
37
|
+
_thread.start()
|
|
38
|
+
return _loop
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
42
|
+
"""Run an awaitable synchronously, blocking until it completes.
|
|
43
|
+
|
|
44
|
+
Safe to call from any synchronous context — even after a previous
|
|
45
|
+
``asyncio.run()`` has closed its loop.
|
|
46
|
+
"""
|
|
47
|
+
loop = _get_loop()
|
|
48
|
+
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
49
|
+
return future.result()
|
dot_inference/_types.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Shared types for dot-llm."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncGenerator, Callable, Coroutine
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, Literal, Protocol
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
# --- Message types ---
|
|
15
|
+
# Duplicated in dot_agent/types.py to keep both packages independent.
|
|
16
|
+
# If you change these, update the dot-agent copy too.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Role(StrEnum):
|
|
20
|
+
"""Roles in an LLM conversation."""
|
|
21
|
+
|
|
22
|
+
SYSTEM = "system"
|
|
23
|
+
USER = "user"
|
|
24
|
+
ASSISTANT = "assistant"
|
|
25
|
+
TOOL = "tool"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# to expose to the front user
|
|
29
|
+
class Message(BaseModel):
|
|
30
|
+
"""A single message in OpenAI chat-completion format.
|
|
31
|
+
|
|
32
|
+
Only ``role`` is required. Provider-specific keys (e.g. ``name``,
|
|
33
|
+
``function_call``, multimodal content arrays) are accepted via
|
|
34
|
+
``extra="allow"`` and passed through untouched.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(extra="allow")
|
|
38
|
+
|
|
39
|
+
role: Role
|
|
40
|
+
content: str | list[dict[str, Any]] | None = None
|
|
41
|
+
# Present on assistant messages when the model invokes tools.
|
|
42
|
+
tool_calls: list[Any] | None = None
|
|
43
|
+
# Present on tool-role messages to link the result back to the call.
|
|
44
|
+
tool_call_id: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- LLM interface types ---
|
|
48
|
+
# Duplicated in dot_agent/types.py to keep both packages independent.
|
|
49
|
+
# If you change these, update the dot-agent copy too.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ToolDef(BaseModel):
|
|
53
|
+
"""A tool definition passed to the LLM via the native tool-calling API."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
description: str
|
|
57
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- LLM response types ---
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# to expose to the front USER
|
|
64
|
+
class ToolCall(BaseModel):
|
|
65
|
+
"""A tool-call request emitted by the LLM via native tool calling."""
|
|
66
|
+
|
|
67
|
+
id: str = Field(description="Provider-assigned call ID (needed for the tool role)")
|
|
68
|
+
name: str
|
|
69
|
+
arguments: str = Field(description="JSON-encoded arguments")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Tool-call results are sent back via Message(role=Role.TOOL, tool_call_id=..., content=...).
|
|
73
|
+
# No dedicated class needed — the Message model already covers this. (delegated properly to dot-agent)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# TODO: vitrine pour le streaming à exposer pour le front.
|
|
77
|
+
class LLMResponse(BaseModel):
|
|
78
|
+
"""Normalized response from an LLM call."""
|
|
79
|
+
|
|
80
|
+
content: str | None = None
|
|
81
|
+
tool_calls: list[ToolCall] | None = None
|
|
82
|
+
structured_output: BaseModel | None = None
|
|
83
|
+
cost: float | None = None
|
|
84
|
+
prompt_tokens: int | None = None
|
|
85
|
+
completion_tokens: int | None = None
|
|
86
|
+
cached_tokens: int | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Streaming event types ---
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# to expose to the front USER
|
|
93
|
+
class TextDelta(BaseModel):
|
|
94
|
+
"""A chunk of text content from the LLM."""
|
|
95
|
+
|
|
96
|
+
type: Literal["text_delta"] = "text_delta"
|
|
97
|
+
content: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# to expose to the front USER
|
|
101
|
+
class ToolCallDone(BaseModel):
|
|
102
|
+
"""A fully assembled tool call."""
|
|
103
|
+
|
|
104
|
+
type: Literal["tool_call_done"] = "tool_call_done"
|
|
105
|
+
tool_call: ToolCall
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# to expose after each message sent to user
|
|
109
|
+
class StreamUsage(BaseModel):
|
|
110
|
+
"""Token usage and cost info, emitted near the end of the stream."""
|
|
111
|
+
|
|
112
|
+
type: Literal["usage"] = "usage"
|
|
113
|
+
prompt_tokens: int | None = None
|
|
114
|
+
completion_tokens: int | None = None
|
|
115
|
+
cached_tokens: int | None = None
|
|
116
|
+
cost: float | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ReasoningDelta(BaseModel):
|
|
120
|
+
"""A chunk of reasoning/thinking text from the LLM.
|
|
121
|
+
|
|
122
|
+
What this carries depends on the provider: open models served by
|
|
123
|
+
vLLM/DeepSeek-style backends stream the raw chain of thought
|
|
124
|
+
(``delta.reasoning_content``), OpenRouter normalizes reasoning to
|
|
125
|
+
``delta.reasoning``, while OpenAI models never expose raw reasoning
|
|
126
|
+
— at best a summarized version, and nothing at all on the plain
|
|
127
|
+
Chat Completions API. Display-only: never feed it back into the
|
|
128
|
+
conversation.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
type: Literal["reasoning_delta"] = "reasoning_delta"
|
|
132
|
+
content: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class InputJsonDelta(BaseModel):
|
|
136
|
+
"""A chunk of tool-call argument JSON from the LLM.
|
|
137
|
+
|
|
138
|
+
Emitted incrementally as the LLM streams tool-call input, *before*
|
|
139
|
+
the fully assembled ``ToolCallDone`` event. Consumers can use this
|
|
140
|
+
to extract and display partial structured output (e.g. streaming the
|
|
141
|
+
text part of a final-answer tool).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
type: Literal["input_json_delta"] = "input_json_delta"
|
|
145
|
+
tool_call_index: int
|
|
146
|
+
tool_name: str
|
|
147
|
+
partial_json: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class StreamDone(BaseModel):
|
|
151
|
+
"""Sentinel event: the stream is complete. Carries the final assembled LLMResponse."""
|
|
152
|
+
|
|
153
|
+
type: Literal["done"] = "done"
|
|
154
|
+
response: LLMResponse
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
StreamEvent = TextDelta | ReasoningDelta | InputJsonDelta | ToolCallDone | StreamUsage | StreamDone
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def text_stream(
|
|
161
|
+
events: AsyncGenerator[StreamEvent, None],
|
|
162
|
+
) -> AsyncGenerator[str, None]:
|
|
163
|
+
"""Extract just the text deltas from a stream."""
|
|
164
|
+
async for event in events:
|
|
165
|
+
if isinstance(event, TextDelta):
|
|
166
|
+
yield event.content
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- Callable signature ---
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class LLMCallable(Protocol):
|
|
173
|
+
"""Async LLM call: ``(messages, response_format, tools) -> LLMResponse``.
|
|
174
|
+
|
|
175
|
+
A Protocol rather than a plain ``Callable`` alias so that
|
|
176
|
+
``response_format`` and ``tools`` stay optional at the call site, which
|
|
177
|
+
is how every implementation and caller actually uses them.
|
|
178
|
+
|
|
179
|
+
The return is a ``Coroutine`` rather than the looser ``Awaitable``:
|
|
180
|
+
``make_sync()`` drives these through ``asyncio.run_coroutine_threadsafe``,
|
|
181
|
+
which rejects awaitables that aren't coroutines.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __call__(
|
|
185
|
+
self,
|
|
186
|
+
messages: list[Message],
|
|
187
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
188
|
+
tools: list[ToolDef] | None = None,
|
|
189
|
+
) -> Coroutine[Any, Any, LLMResponse]: ...
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
SyncLLMCallable = Callable[
|
|
193
|
+
[list[Message], type[BaseModel] | dict[str, Any] | None, list[ToolDef] | None],
|
|
194
|
+
LLMResponse,
|
|
195
|
+
]
|
|
196
|
+
"""Synchronous version of ``LLMCallable``."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def make_sync(fn: LLMCallable) -> SyncLLMCallable:
|
|
200
|
+
"""Wrap an async ``LLMCallable`` into a synchronous callable.
|
|
201
|
+
|
|
202
|
+
Uses a persistent background event loop so that async SDK clients
|
|
203
|
+
(httpx, etc.) can clean up properly without "Event loop is closed" errors.
|
|
204
|
+
"""
|
|
205
|
+
from dot_inference._sync import run_sync
|
|
206
|
+
|
|
207
|
+
def wrapper(
|
|
208
|
+
messages: list[Message],
|
|
209
|
+
schema: type[BaseModel] | dict[str, Any] | None = None,
|
|
210
|
+
tools: list[ToolDef] | None = None,
|
|
211
|
+
) -> LLMResponse:
|
|
212
|
+
return run_sync(fn(messages, schema, tools))
|
|
213
|
+
|
|
214
|
+
return wrapper
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
"""Signature: (messages, response_format_or_none, tools_or_none) -> LLMResponse"""
|
|
218
|
+
|
|
219
|
+
LLMStreamCallable = Callable[
|
|
220
|
+
[list[Message], type[BaseModel] | dict[str, Any] | None, list[ToolDef] | None],
|
|
221
|
+
AsyncGenerator[StreamEvent, None],
|
|
222
|
+
]
|
|
223
|
+
"""Signature: (messages, response_format, tools) -> AsyncGenerator yielding StreamEvent."""
|
|
224
|
+
|
|
225
|
+
EmbeddingCallable = Callable[
|
|
226
|
+
[list[str]],
|
|
227
|
+
Coroutine[Any, Any, list[list[float]]],
|
|
228
|
+
]
|
|
229
|
+
"""Signature: (texts) -> list of embedding vectors. Batching is handled internally."""
|
dot_inference/mock.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Mock clients for testing without API calls."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from collections.abc import AsyncGenerator
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from dot_inference._client import EmbeddingClient, LLMClient
|
|
15
|
+
from dot_inference._types import (
|
|
16
|
+
LLMResponse,
|
|
17
|
+
Message,
|
|
18
|
+
StreamDone,
|
|
19
|
+
StreamEvent,
|
|
20
|
+
StreamUsage,
|
|
21
|
+
TextDelta,
|
|
22
|
+
ToolCall,
|
|
23
|
+
ToolCallDone,
|
|
24
|
+
ToolDef,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_dummy_instance(model_cls: type[BaseModel]) -> BaseModel:
|
|
29
|
+
"""Recursively build a dummy instance of a Pydantic model."""
|
|
30
|
+
values: dict[str, Any] = {}
|
|
31
|
+
for name, field_info in model_cls.model_fields.items():
|
|
32
|
+
annotation = field_info.annotation
|
|
33
|
+
if annotation is bool:
|
|
34
|
+
values[name] = True
|
|
35
|
+
elif annotation is int:
|
|
36
|
+
values[name] = 42
|
|
37
|
+
elif annotation is float:
|
|
38
|
+
values[name] = 3.14
|
|
39
|
+
elif annotation is str:
|
|
40
|
+
values[name] = f"mock_{name}"
|
|
41
|
+
elif hasattr(annotation, "__members__"): # Enum types
|
|
42
|
+
values[name] = next(iter(annotation.__members__.values()))
|
|
43
|
+
elif isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
44
|
+
values[name] = make_dummy_instance(annotation)
|
|
45
|
+
else:
|
|
46
|
+
values[name] = None
|
|
47
|
+
return model_cls(**values)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MockLLMClient(LLMClient):
|
|
51
|
+
"""LLM client that returns canned responses without API calls.
|
|
52
|
+
|
|
53
|
+
Useful for testing pipelines without incurring costs or requiring
|
|
54
|
+
API keys.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self._client = None
|
|
60
|
+
self._model = "mock"
|
|
61
|
+
self._model_config = None
|
|
62
|
+
self._temperature = None
|
|
63
|
+
self._reasoning_effort = None
|
|
64
|
+
self._max_tokens = None
|
|
65
|
+
|
|
66
|
+
async def call(
|
|
67
|
+
self,
|
|
68
|
+
messages: list[Message],
|
|
69
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
70
|
+
tools: list[ToolDef] | None = None,
|
|
71
|
+
*,
|
|
72
|
+
temperature: float | None = None,
|
|
73
|
+
reasoning_effort: str | None = None,
|
|
74
|
+
tool_choice: str | None = None,
|
|
75
|
+
) -> LLMResponse:
|
|
76
|
+
structured_output = None
|
|
77
|
+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
|
78
|
+
structured_output = make_dummy_instance(response_format)
|
|
79
|
+
|
|
80
|
+
user_content = (messages[-1].content or "") if messages else ""
|
|
81
|
+
content = f"[MOCK] User: {user_content}"
|
|
82
|
+
|
|
83
|
+
return LLMResponse(
|
|
84
|
+
content=content,
|
|
85
|
+
structured_output=structured_output,
|
|
86
|
+
cost=0.0,
|
|
87
|
+
prompt_tokens=0,
|
|
88
|
+
completion_tokens=0,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def stream(
|
|
92
|
+
self,
|
|
93
|
+
messages: list[Message],
|
|
94
|
+
response_format: type[BaseModel] | dict[str, Any] | None = None,
|
|
95
|
+
tools: list[ToolDef] | None = None,
|
|
96
|
+
*,
|
|
97
|
+
temperature: float | None = None,
|
|
98
|
+
reasoning_effort: str | None = None,
|
|
99
|
+
tool_choice: str | None = None,
|
|
100
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
101
|
+
user_content = (messages[-1].content or "") if messages else ""
|
|
102
|
+
|
|
103
|
+
if tools:
|
|
104
|
+
mock_tool_calls: list[ToolCall] = []
|
|
105
|
+
for i, tool in enumerate(tools):
|
|
106
|
+
mock_args = '{"mock": true}'
|
|
107
|
+
tc = ToolCall(id=f"mock_call_{i}", name=tool.name, arguments=mock_args)
|
|
108
|
+
yield ToolCallDone(tool_call=tc)
|
|
109
|
+
mock_tool_calls.append(tc)
|
|
110
|
+
yield StreamUsage(prompt_tokens=0, completion_tokens=0, cost=0.0)
|
|
111
|
+
yield StreamDone(
|
|
112
|
+
response=LLMResponse(
|
|
113
|
+
tool_calls=mock_tool_calls,
|
|
114
|
+
cost=0.0,
|
|
115
|
+
prompt_tokens=0,
|
|
116
|
+
completion_tokens=0,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
fake_response = f"[MOCK STREAM] {user_content}"
|
|
121
|
+
for i in range(0, len(fake_response), 10):
|
|
122
|
+
await asyncio.sleep(0.01)
|
|
123
|
+
yield TextDelta(content=fake_response[i : i + 10])
|
|
124
|
+
|
|
125
|
+
structured_output = None
|
|
126
|
+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
|
127
|
+
structured_output = make_dummy_instance(response_format)
|
|
128
|
+
|
|
129
|
+
yield StreamUsage(prompt_tokens=0, completion_tokens=0, cost=0.0)
|
|
130
|
+
yield StreamDone(
|
|
131
|
+
response=LLMResponse(
|
|
132
|
+
content=fake_response,
|
|
133
|
+
structured_output=structured_output,
|
|
134
|
+
cost=0.0,
|
|
135
|
+
prompt_tokens=0,
|
|
136
|
+
completion_tokens=0,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MockEmbeddingClient(EmbeddingClient):
|
|
142
|
+
"""Embedding client that returns deterministic vectors without API calls."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, dimension: int = 8) -> None:
|
|
145
|
+
self._client = None
|
|
146
|
+
self._model = "mock-embed"
|
|
147
|
+
self._batch_size = 0
|
|
148
|
+
self._dimension = dimension
|
|
149
|
+
|
|
150
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
151
|
+
return [[float(i) * 0.1] * self._dimension for i in range(len(texts))]
|