galet 0.1.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.
- galet/__init__.py +43 -0
- galet/adapter_interface.py +53 -0
- galet/deepseek_responses.py +359 -0
- galet/dto.py +35 -0
- galet/embedding_dto.py +16 -0
- galet/embedding_interface.py +16 -0
- galet/embedding_router.py +35 -0
- galet/gemini_api.py +354 -0
- galet/gemini_imagegen.py +186 -0
- galet/imagegen_dto.py +22 -0
- galet/imagegen_interface.py +19 -0
- galet/imagegen_router.py +54 -0
- galet/interface.py +31 -0
- galet/mistral_api.py +432 -0
- galet/mistral_embedding.py +133 -0
- galet/mistral_responses_adapter.py +88 -0
- galet/ollama_api.py +362 -0
- galet/openai_embedding.py +163 -0
- galet/openai_imagegen.py +196 -0
- galet/openai_responses.py +369 -0
- galet/openai_responses_adapter.py +98 -0
- galet/provider_registry.py +116 -0
- galet/router_api.py +82 -0
- galet/settings.py +72 -0
- galet/tool_output.py +20 -0
- galet-0.1.0.dist-info/METADATA +102 -0
- galet-0.1.0.dist-info/RECORD +30 -0
- galet-0.1.0.dist-info/WHEEL +5 -0
- galet-0.1.0.dist-info/licenses/LICENSE +21 -0
- galet-0.1.0.dist-info/top_level.txt +1 -0
galet/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from .dto import LLMResponse, LLMUsage, ToolCall
|
|
2
|
+
from .interface import LLMApi
|
|
3
|
+
from .adapter_interface import LLMAdapter
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .openai_responses import OpenAIResponsesApi
|
|
7
|
+
from .openai_responses_adapter import OpenAIResponsesAdapter
|
|
8
|
+
except Exception:
|
|
9
|
+
# openai and related adapters are optional for test environments; avoid
|
|
10
|
+
# failing import when the 'openai' package is not installed.
|
|
11
|
+
OpenAIResponsesApi = None
|
|
12
|
+
OpenAIResponsesAdapter = None
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from .mistral_api import MistralApi
|
|
16
|
+
from .mistral_responses_adapter import MistralResponsesAdapter
|
|
17
|
+
except Exception:
|
|
18
|
+
MistralApi = None
|
|
19
|
+
MistralResponsesAdapter = None
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from .ollama_api import OllamaApi
|
|
23
|
+
except Exception:
|
|
24
|
+
OllamaApi = None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from .gemini_api import GeminiApi
|
|
28
|
+
except Exception:
|
|
29
|
+
GeminiApi = None
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LLMApi",
|
|
33
|
+
"LLMAdapter",
|
|
34
|
+
"LLMResponse",
|
|
35
|
+
"LLMUsage",
|
|
36
|
+
"ToolCall",
|
|
37
|
+
"OpenAIResponsesApi",
|
|
38
|
+
"OpenAIResponsesAdapter",
|
|
39
|
+
"MistralApi",
|
|
40
|
+
"MistralResponsesAdapter",
|
|
41
|
+
"OllamaApi",
|
|
42
|
+
"GeminiApi",
|
|
43
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, Protocol
|
|
4
|
+
|
|
5
|
+
from .dto import LLMUsage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMAdapter(Protocol):
|
|
9
|
+
"""Protocol glue between the FunctionCallingProcessor and a specific LLM API.
|
|
10
|
+
|
|
11
|
+
The processor should remain LLM-agnostic. The adapter is responsible for:
|
|
12
|
+
- calling the model
|
|
13
|
+
- extracting tool calls in a normalized shape
|
|
14
|
+
- formatting tool outputs in the model's expected protocol
|
|
15
|
+
|
|
16
|
+
For now, we keep messages and tool definitions OpenAI-shaped.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def supports_image_processing(self, model: str, provider: Optional[str] = None) -> bool:
|
|
20
|
+
"""Return True if the selected model can natively process images."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def call_model(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
model: str,
|
|
27
|
+
input: Any,
|
|
28
|
+
temperature: Optional[float] = None,
|
|
29
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
30
|
+
tool_choice: Optional[str] = None,
|
|
31
|
+
store: Optional[bool] = None,
|
|
32
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
33
|
+
previous_response_id: Optional[str] = None,
|
|
34
|
+
text: Optional[Dict[str, Any]] = None,
|
|
35
|
+
provider: Optional[str] = None,
|
|
36
|
+
) -> Any: ...
|
|
37
|
+
|
|
38
|
+
def extract_tool_calls(self, response: Any) -> List[Dict[str, Any]]: ...
|
|
39
|
+
|
|
40
|
+
def format_tool_output(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
call_id: str,
|
|
44
|
+
output: str,
|
|
45
|
+
name: Optional[str] = None,
|
|
46
|
+
provider: Optional[str] = None,
|
|
47
|
+
) -> Dict[str, Any]: ...
|
|
48
|
+
|
|
49
|
+
def get_text(self, response: Any) -> str: ...
|
|
50
|
+
|
|
51
|
+
def get_response_id(self, response: Any) -> Optional[str]: ...
|
|
52
|
+
|
|
53
|
+
def get_usage(self, response: Any) -> Optional[LLMUsage]: ...
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from openai import OpenAI
|
|
7
|
+
|
|
8
|
+
from .dto import LLMResponse, LLMUsage, ToolCall
|
|
9
|
+
from .interface import LLMApi
|
|
10
|
+
from .openai_responses import _sleep_backoff
|
|
11
|
+
from .settings import Settings, default_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeepSeekApi(LLMApi):
|
|
15
|
+
"""DeepSeek API implementation using OpenAI-compatible endpoint.
|
|
16
|
+
|
|
17
|
+
DeepSeek is text-only. For image support, the FCP layer delegates
|
|
18
|
+
to a vision-capable agent via delegate_tasks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
client: Optional[OpenAI] = None,
|
|
27
|
+
max_attempts: int = 4,
|
|
28
|
+
backoff_base: float = 0.5,
|
|
29
|
+
backoff_cap: float = 8.0,
|
|
30
|
+
settings: Optional[Settings] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._client = client
|
|
33
|
+
self._settings = settings or default_settings
|
|
34
|
+
self._max_attempts = max_attempts
|
|
35
|
+
self._backoff_base = backoff_base
|
|
36
|
+
self._backoff_cap = backoff_cap
|
|
37
|
+
# Store conversation context by response_id
|
|
38
|
+
self._conversation_context: Dict[str, List[Dict[str, Any]]] = {}
|
|
39
|
+
|
|
40
|
+
def _get_client(self) -> OpenAI:
|
|
41
|
+
if self._client is None:
|
|
42
|
+
self._client = self._build_default_client(self._settings)
|
|
43
|
+
return self._client
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _build_default_client(settings: Optional[Settings] = None) -> OpenAI:
|
|
47
|
+
return OpenAI(
|
|
48
|
+
api_key=(settings or default_settings).api_key("deepseek"),
|
|
49
|
+
base_url=DeepSeekApi.DEEPSEEK_BASE_URL,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# supports_image_processing
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def supports_image_processing(self, model: str) -> bool:
|
|
57
|
+
"""DeepSeek is text-only — no native image processing."""
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# Tool format transform
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _transform_tools_for_deepseek(self, tools: Optional[list[dict]]) -> Optional[list[dict]]:
|
|
65
|
+
"""Transform OpenAI tool format to DeepSeek format."""
|
|
66
|
+
if not tools:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
deepseek_tools = []
|
|
70
|
+
for tool in tools:
|
|
71
|
+
if tool.get("type") == "function":
|
|
72
|
+
function_def = {}
|
|
73
|
+
|
|
74
|
+
if "name" in tool:
|
|
75
|
+
function_def["name"] = tool["name"]
|
|
76
|
+
|
|
77
|
+
if "description" in tool:
|
|
78
|
+
function_def["description"] = tool["description"]
|
|
79
|
+
|
|
80
|
+
if "parameters" in tool:
|
|
81
|
+
params = tool["parameters"].copy() if isinstance(tool["parameters"], dict) else {}
|
|
82
|
+
params.pop("strict", None)
|
|
83
|
+
params.pop("additionalProperties", None)
|
|
84
|
+
function_def["parameters"] = params
|
|
85
|
+
|
|
86
|
+
deepseek_tools.append({
|
|
87
|
+
"type": "function",
|
|
88
|
+
"function": function_def
|
|
89
|
+
})
|
|
90
|
+
else:
|
|
91
|
+
deepseek_tools.append(tool)
|
|
92
|
+
|
|
93
|
+
return deepseek_tools
|
|
94
|
+
|
|
95
|
+
def _convert_tool_calls_to_assistant_message(self, tool_calls: List[ToolCall]) -> Dict[str, Any]:
|
|
96
|
+
"""Convert ToolCall objects to an assistant message with tool_calls."""
|
|
97
|
+
formatted_tool_calls = []
|
|
98
|
+
for tc in tool_calls:
|
|
99
|
+
formatted_tool_calls.append({
|
|
100
|
+
"id": tc.call_id,
|
|
101
|
+
"type": "function",
|
|
102
|
+
"function": {
|
|
103
|
+
"name": tc.name,
|
|
104
|
+
"arguments": tc.arguments_json
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"role": "assistant",
|
|
110
|
+
"content": None,
|
|
111
|
+
"tool_calls": formatted_tool_calls
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _reconstruct_tool_calls_from_outputs(
|
|
116
|
+
items: List[Dict[str, Any]],
|
|
117
|
+
) -> List[ToolCall]:
|
|
118
|
+
"""Build minimal ToolCall stubs from function_call_output items.
|
|
119
|
+
|
|
120
|
+
Used as a defensive fallback when _conversation_context is missing
|
|
121
|
+
and no previous_tool_calls metadata was provided. The call_id is
|
|
122
|
+
the critical field — the API uses it to match tool messages to
|
|
123
|
+
the assistant's tool_calls. Tool name is a placeholder since it
|
|
124
|
+
isn't validated for this purpose.
|
|
125
|
+
"""
|
|
126
|
+
seen: set[str] = set()
|
|
127
|
+
stubs: List[ToolCall] = []
|
|
128
|
+
for item in items:
|
|
129
|
+
if item.get("type") != "function_call_output":
|
|
130
|
+
continue
|
|
131
|
+
call_id = item.get("call_id", "")
|
|
132
|
+
if not call_id or call_id in seen:
|
|
133
|
+
continue
|
|
134
|
+
seen.add(call_id)
|
|
135
|
+
stubs.append(ToolCall(
|
|
136
|
+
call_id=str(call_id),
|
|
137
|
+
name="__reconstructed__",
|
|
138
|
+
arguments_json="{}",
|
|
139
|
+
))
|
|
140
|
+
return stubs
|
|
141
|
+
|
|
142
|
+
def _normalize_input_to_messages(
|
|
143
|
+
self,
|
|
144
|
+
input: Any,
|
|
145
|
+
previous_response_id: Optional[str] = None,
|
|
146
|
+
previous_tool_calls: Optional[List[ToolCall]] = None
|
|
147
|
+
) -> List[Dict[str, Any]]:
|
|
148
|
+
"""Convert various input formats to a list of messages for DeepSeek."""
|
|
149
|
+
|
|
150
|
+
# Case 1: Input is a list of tool outputs from the processor
|
|
151
|
+
if isinstance(input, list) and input and isinstance(input[0], dict):
|
|
152
|
+
if "type" in input[0] and input[0].get("type") == "function_call_output":
|
|
153
|
+
# This is a tool response. We need to combine with previous context.
|
|
154
|
+
context_messages = []
|
|
155
|
+
if previous_response_id and previous_response_id in self._conversation_context:
|
|
156
|
+
context_messages = self._conversation_context[previous_response_id].copy()
|
|
157
|
+
logging.info(f"DeepSeekApi: retrieved {len(context_messages)} messages from context for response_id={previous_response_id}")
|
|
158
|
+
|
|
159
|
+
# Add the assistant message with tool_calls if we have them
|
|
160
|
+
if previous_tool_calls:
|
|
161
|
+
assistant_msg = self._convert_tool_calls_to_assistant_message(previous_tool_calls)
|
|
162
|
+
context_messages.append(assistant_msg)
|
|
163
|
+
elif not context_messages:
|
|
164
|
+
# Defensive fallback: reconstruct tool_calls from the outputs themselves.
|
|
165
|
+
# This handles the case where _conversation_context is empty (e.g.
|
|
166
|
+
# response_id was None, or context was never stored for this ID) and
|
|
167
|
+
# the caller didn't pass previous_tool_calls in metadata.
|
|
168
|
+
reconstructed = self._reconstruct_tool_calls_from_outputs(input)
|
|
169
|
+
if reconstructed:
|
|
170
|
+
logging.warning(
|
|
171
|
+
"DeepSeekApi: _conversation_context missing for response_id=%s "
|
|
172
|
+
"and no previous_tool_calls in metadata — reconstructed %d tool_calls from function_call_output items",
|
|
173
|
+
previous_response_id,
|
|
174
|
+
len(reconstructed),
|
|
175
|
+
)
|
|
176
|
+
assistant_msg = self._convert_tool_calls_to_assistant_message(reconstructed)
|
|
177
|
+
context_messages.append(assistant_msg)
|
|
178
|
+
|
|
179
|
+
# Convert tool outputs to tool response messages
|
|
180
|
+
for item in input:
|
|
181
|
+
if item.get("type") == "function_call_output":
|
|
182
|
+
tool_message = {
|
|
183
|
+
"role": "tool",
|
|
184
|
+
"tool_call_id": item.get("call_id"),
|
|
185
|
+
"content": item.get("output", "")
|
|
186
|
+
}
|
|
187
|
+
context_messages.append(tool_message)
|
|
188
|
+
|
|
189
|
+
logging.info(f"DeepSeekApi: built conversation with {len(context_messages)} total messages")
|
|
190
|
+
return context_messages
|
|
191
|
+
|
|
192
|
+
# Case 2: Input is already a list of messages — pass through as-is
|
|
193
|
+
if isinstance(input, list):
|
|
194
|
+
return input
|
|
195
|
+
|
|
196
|
+
# Case 3: Input is a single message
|
|
197
|
+
if isinstance(input, dict):
|
|
198
|
+
return [input]
|
|
199
|
+
|
|
200
|
+
# Case 4: Unknown format
|
|
201
|
+
logging.warning(f"DeepSeekApi: unexpected input type: {type(input)}")
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
def _validate_and_fix_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
205
|
+
"""Ensure all messages have required fields and proper format."""
|
|
206
|
+
fixed_messages = []
|
|
207
|
+
|
|
208
|
+
for i, msg in enumerate(messages):
|
|
209
|
+
fixed_msg = dict(msg)
|
|
210
|
+
|
|
211
|
+
# Ensure role exists
|
|
212
|
+
if "role" not in fixed_msg:
|
|
213
|
+
logging.error(f"Message at index {i} missing 'role' field: {fixed_msg}")
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
# Ensure assistant messages with tool_calls have proper structure
|
|
217
|
+
if fixed_msg.get("role") == "assistant":
|
|
218
|
+
if "tool_calls" in fixed_msg and fixed_msg["tool_calls"]:
|
|
219
|
+
# Ensure each tool call has the proper structure
|
|
220
|
+
for tc in fixed_msg["tool_calls"]:
|
|
221
|
+
if isinstance(tc, dict) and "function" not in tc:
|
|
222
|
+
# Convert from flat format to nested format if needed
|
|
223
|
+
if "name" in tc and "arguments" in tc:
|
|
224
|
+
tc["function"] = {
|
|
225
|
+
"name": tc.pop("name"),
|
|
226
|
+
"arguments": tc.pop("arguments")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
fixed_messages.append(fixed_msg)
|
|
230
|
+
|
|
231
|
+
return fixed_messages
|
|
232
|
+
|
|
233
|
+
def create_response(
|
|
234
|
+
self,
|
|
235
|
+
*,
|
|
236
|
+
model: str,
|
|
237
|
+
input: Any,
|
|
238
|
+
temperature: Optional[float] = None,
|
|
239
|
+
tools: Optional[list[dict]] = None,
|
|
240
|
+
tool_choice: Optional[str] = None,
|
|
241
|
+
store: Optional[bool] = None,
|
|
242
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
243
|
+
previous_response_id: Optional[str] = None,
|
|
244
|
+
text: Optional[Dict[str, Any]] = None,
|
|
245
|
+
) -> LLMResponse:
|
|
246
|
+
# Transform tools to DeepSeek format
|
|
247
|
+
deepseek_tools = self._transform_tools_for_deepseek(tools)
|
|
248
|
+
|
|
249
|
+
# Extract previous tool_calls from metadata if available
|
|
250
|
+
previous_tool_calls = None
|
|
251
|
+
if metadata and "previous_tool_calls" in metadata:
|
|
252
|
+
previous_tool_calls = metadata["previous_tool_calls"]
|
|
253
|
+
|
|
254
|
+
# Normalize input to messages list
|
|
255
|
+
messages = self._normalize_input_to_messages(input, previous_response_id, previous_tool_calls)
|
|
256
|
+
|
|
257
|
+
# Validate and fix messages
|
|
258
|
+
fixed_messages = self._validate_and_fix_messages(messages)
|
|
259
|
+
|
|
260
|
+
logging.info("DeepSeekApi: model=%s temperature=%s tool_choice=%s tools_count=%d messages_count=%d previous_response_id=%s",
|
|
261
|
+
model, temperature, tool_choice, len(deepseek_tools) if deepseek_tools else 0,
|
|
262
|
+
len(fixed_messages), previous_response_id)
|
|
263
|
+
|
|
264
|
+
# Check for empty messages
|
|
265
|
+
if not fixed_messages:
|
|
266
|
+
logging.error("DeepSeekApi: no messages to send after normalization")
|
|
267
|
+
raise ValueError("No messages to send to DeepSeek API")
|
|
268
|
+
|
|
269
|
+
for attempt in range(self._max_attempts):
|
|
270
|
+
try:
|
|
271
|
+
# Prepare request parameters
|
|
272
|
+
request_params = {
|
|
273
|
+
"model": model,
|
|
274
|
+
"messages": fixed_messages,
|
|
275
|
+
"temperature": temperature,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if deepseek_tools:
|
|
279
|
+
request_params["tools"] = deepseek_tools
|
|
280
|
+
|
|
281
|
+
if tool_choice:
|
|
282
|
+
request_params["tool_choice"] = tool_choice
|
|
283
|
+
|
|
284
|
+
resp = self._get_client().chat.completions.create(**request_params)
|
|
285
|
+
|
|
286
|
+
# Extract from chat completion response format
|
|
287
|
+
response_id = getattr(resp, "id", None)
|
|
288
|
+
resp_model = getattr(resp, "model", None)
|
|
289
|
+
|
|
290
|
+
# Extract text from the first choice
|
|
291
|
+
output_text = ""
|
|
292
|
+
if resp.choices and len(resp.choices) > 0:
|
|
293
|
+
message = resp.choices[0].message
|
|
294
|
+
output_text = message.content or ""
|
|
295
|
+
|
|
296
|
+
# Extract tool calls from the response
|
|
297
|
+
tool_calls_list = []
|
|
298
|
+
if resp.choices and len(resp.choices) > 0:
|
|
299
|
+
message = resp.choices[0].message
|
|
300
|
+
if hasattr(message, "tool_calls") and message.tool_calls:
|
|
301
|
+
for tc in message.tool_calls:
|
|
302
|
+
tool_calls_list.append(ToolCall(
|
|
303
|
+
call_id=tc.id,
|
|
304
|
+
name=tc.function.name,
|
|
305
|
+
arguments_json=tc.function.arguments,
|
|
306
|
+
))
|
|
307
|
+
|
|
308
|
+
# Store the conversation context for future tool responses
|
|
309
|
+
if response_id:
|
|
310
|
+
self._conversation_context[response_id] = fixed_messages.copy()
|
|
311
|
+
# Also store the assistant's response if it had tool_calls
|
|
312
|
+
if tool_calls_list:
|
|
313
|
+
assistant_msg = self._convert_tool_calls_to_assistant_message(tool_calls_list)
|
|
314
|
+
self._conversation_context[response_id].append(assistant_msg)
|
|
315
|
+
logging.debug(f"DeepSeekApi: stored context for response_id={response_id} with {len(self._conversation_context[response_id])} messages")
|
|
316
|
+
|
|
317
|
+
# Extract usage
|
|
318
|
+
usage = None
|
|
319
|
+
if hasattr(resp, "usage") and resp.usage:
|
|
320
|
+
usage = LLMUsage(
|
|
321
|
+
input_tokens=getattr(resp.usage, "prompt_tokens", None),
|
|
322
|
+
output_tokens=getattr(resp.usage, "completion_tokens", None),
|
|
323
|
+
total_tokens=getattr(resp.usage, "total_tokens", None),
|
|
324
|
+
raw=resp.usage,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
logging.info(
|
|
328
|
+
"DeepSeekApi: response_id=%s model=%s output_text_len=%d tool_calls=%d",
|
|
329
|
+
response_id,
|
|
330
|
+
resp_model,
|
|
331
|
+
len(output_text),
|
|
332
|
+
len(tool_calls_list),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return LLMResponse(
|
|
336
|
+
response_id=response_id,
|
|
337
|
+
model=resp_model,
|
|
338
|
+
output_text=output_text,
|
|
339
|
+
tool_calls=tool_calls_list,
|
|
340
|
+
usage=usage,
|
|
341
|
+
raw=resp,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logging.warning(
|
|
346
|
+
"DeepSeekApi: attempt %d/%d failed: %s",
|
|
347
|
+
attempt + 1,
|
|
348
|
+
self._max_attempts,
|
|
349
|
+
e,
|
|
350
|
+
)
|
|
351
|
+
if attempt == self._max_attempts - 1:
|
|
352
|
+
logging.error("DeepSeekApi: failed with messages count: %d", len(fixed_messages))
|
|
353
|
+
if fixed_messages:
|
|
354
|
+
for i, msg in enumerate(fixed_messages[:3]): # Log first 3 messages
|
|
355
|
+
logging.error(f"DeepSeekApi: message[{i}]: role={msg.get('role')} keys={list(msg.keys())}")
|
|
356
|
+
raise
|
|
357
|
+
_sleep_backoff(attempt, self._backoff_base, self._backoff_cap)
|
|
358
|
+
|
|
359
|
+
raise RuntimeError("DeepSeekApi: exhausted retries")
|
galet/dto.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ToolCall:
|
|
9
|
+
"""A normalized tool call extracted from an LLM response."""
|
|
10
|
+
|
|
11
|
+
call_id: str
|
|
12
|
+
name: str
|
|
13
|
+
arguments_json: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LLMUsage:
|
|
18
|
+
"""Normalized usage info (best-effort)."""
|
|
19
|
+
|
|
20
|
+
input_tokens: Optional[int] = None
|
|
21
|
+
output_tokens: Optional[int] = None
|
|
22
|
+
total_tokens: Optional[int] = None
|
|
23
|
+
raw: Optional[Dict[str, Any]] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class LLMResponse:
|
|
28
|
+
"""Normalized response returned by LLMApi implementations."""
|
|
29
|
+
|
|
30
|
+
response_id: Optional[str]
|
|
31
|
+
model: Optional[str]
|
|
32
|
+
output_text: str
|
|
33
|
+
tool_calls: List[ToolCall]
|
|
34
|
+
usage: Optional[LLMUsage] = None
|
|
35
|
+
raw: Optional[Any] = None
|
galet/embedding_dto.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, List, Optional
|
|
5
|
+
|
|
6
|
+
from .dto import LLMUsage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class EmbeddingResponse:
|
|
11
|
+
"""Normalized response from an embedding API call."""
|
|
12
|
+
|
|
13
|
+
model: str
|
|
14
|
+
embeddings: List[List[float]]
|
|
15
|
+
usage: Optional[LLMUsage] = None
|
|
16
|
+
raw: Optional[Any] = None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from .embedding_dto import EmbeddingResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EmbeddingApi(Protocol):
|
|
9
|
+
"""Interface for calling an embeddings model."""
|
|
10
|
+
|
|
11
|
+
def embed(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
model: str,
|
|
15
|
+
input: list[str],
|
|
16
|
+
) -> EmbeddingResponse: ...
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .embedding_dto import EmbeddingResponse
|
|
6
|
+
from .embedding_interface import EmbeddingApi
|
|
7
|
+
from .openai_embedding import OpenAIEmbeddingApi
|
|
8
|
+
from .mistral_embedding import MistralEmbeddingApi
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmbeddingRouter(EmbeddingApi):
|
|
12
|
+
"""Routes embedding requests to the correct backend based on the model name.
|
|
13
|
+
|
|
14
|
+
- Model names starting with ``"mistral"`` → ``MistralEmbeddingApi``
|
|
15
|
+
- All other model names → ``OpenAIEmbeddingApi``
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
openai_api: Optional[OpenAIEmbeddingApi] = None,
|
|
22
|
+
mistral_api: Optional[MistralEmbeddingApi] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._openai = openai_api or OpenAIEmbeddingApi()
|
|
25
|
+
self._mistral = mistral_api or MistralEmbeddingApi()
|
|
26
|
+
|
|
27
|
+
def embed(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
model: str,
|
|
31
|
+
input: list[str],
|
|
32
|
+
) -> EmbeddingResponse:
|
|
33
|
+
if model.startswith("mistral"):
|
|
34
|
+
return self._mistral.embed(model=model, input=input)
|
|
35
|
+
return self._openai.embed(model=model, input=input)
|