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/_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()
@@ -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))]