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 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)