iatoolkit 0.3.9__py3-none-any.whl → 0.107.4__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.

Potentially problematic release.


This version of iatoolkit might be problematic. Click here for more details.

Files changed (150) hide show
  1. iatoolkit/__init__.py +27 -35
  2. iatoolkit/base_company.py +3 -35
  3. iatoolkit/cli_commands.py +18 -47
  4. iatoolkit/common/__init__.py +0 -0
  5. iatoolkit/common/exceptions.py +48 -0
  6. iatoolkit/common/interfaces/__init__.py +0 -0
  7. iatoolkit/common/interfaces/asset_storage.py +34 -0
  8. iatoolkit/common/interfaces/database_provider.py +39 -0
  9. iatoolkit/common/model_registry.py +159 -0
  10. iatoolkit/common/routes.py +138 -0
  11. iatoolkit/common/session_manager.py +26 -0
  12. iatoolkit/common/util.py +353 -0
  13. iatoolkit/company_registry.py +66 -29
  14. iatoolkit/core.py +514 -0
  15. iatoolkit/infra/__init__.py +5 -0
  16. iatoolkit/infra/brevo_mail_app.py +123 -0
  17. iatoolkit/infra/call_service.py +140 -0
  18. iatoolkit/infra/connectors/__init__.py +5 -0
  19. iatoolkit/infra/connectors/file_connector.py +17 -0
  20. iatoolkit/infra/connectors/file_connector_factory.py +57 -0
  21. iatoolkit/infra/connectors/google_cloud_storage_connector.py +53 -0
  22. iatoolkit/infra/connectors/google_drive_connector.py +68 -0
  23. iatoolkit/infra/connectors/local_file_connector.py +46 -0
  24. iatoolkit/infra/connectors/s3_connector.py +33 -0
  25. iatoolkit/infra/google_chat_app.py +57 -0
  26. iatoolkit/infra/llm_providers/__init__.py +0 -0
  27. iatoolkit/infra/llm_providers/deepseek_adapter.py +278 -0
  28. iatoolkit/infra/llm_providers/gemini_adapter.py +350 -0
  29. iatoolkit/infra/llm_providers/openai_adapter.py +124 -0
  30. iatoolkit/infra/llm_proxy.py +268 -0
  31. iatoolkit/infra/llm_response.py +45 -0
  32. iatoolkit/infra/redis_session_manager.py +122 -0
  33. iatoolkit/locales/en.yaml +222 -0
  34. iatoolkit/locales/es.yaml +225 -0
  35. iatoolkit/repositories/__init__.py +5 -0
  36. iatoolkit/repositories/database_manager.py +187 -0
  37. iatoolkit/repositories/document_repo.py +33 -0
  38. iatoolkit/repositories/filesystem_asset_repository.py +36 -0
  39. iatoolkit/repositories/llm_query_repo.py +105 -0
  40. iatoolkit/repositories/models.py +279 -0
  41. iatoolkit/repositories/profile_repo.py +171 -0
  42. iatoolkit/repositories/vs_repo.py +150 -0
  43. iatoolkit/services/__init__.py +5 -0
  44. iatoolkit/services/auth_service.py +193 -0
  45. {services → iatoolkit/services}/benchmark_service.py +7 -7
  46. iatoolkit/services/branding_service.py +153 -0
  47. iatoolkit/services/company_context_service.py +214 -0
  48. iatoolkit/services/configuration_service.py +375 -0
  49. iatoolkit/services/dispatcher_service.py +134 -0
  50. {services → iatoolkit/services}/document_service.py +20 -8
  51. iatoolkit/services/embedding_service.py +148 -0
  52. iatoolkit/services/excel_service.py +156 -0
  53. {services → iatoolkit/services}/file_processor_service.py +36 -21
  54. iatoolkit/services/history_manager_service.py +208 -0
  55. iatoolkit/services/i18n_service.py +104 -0
  56. iatoolkit/services/jwt_service.py +80 -0
  57. iatoolkit/services/language_service.py +89 -0
  58. iatoolkit/services/license_service.py +82 -0
  59. iatoolkit/services/llm_client_service.py +438 -0
  60. iatoolkit/services/load_documents_service.py +174 -0
  61. iatoolkit/services/mail_service.py +213 -0
  62. {services → iatoolkit/services}/profile_service.py +200 -101
  63. iatoolkit/services/prompt_service.py +303 -0
  64. iatoolkit/services/query_service.py +467 -0
  65. iatoolkit/services/search_service.py +55 -0
  66. iatoolkit/services/sql_service.py +169 -0
  67. iatoolkit/services/tool_service.py +246 -0
  68. iatoolkit/services/user_feedback_service.py +117 -0
  69. iatoolkit/services/user_session_context_service.py +213 -0
  70. iatoolkit/static/images/fernando.jpeg +0 -0
  71. iatoolkit/static/images/iatoolkit_core.png +0 -0
  72. iatoolkit/static/images/iatoolkit_logo.png +0 -0
  73. iatoolkit/static/js/chat_feedback_button.js +80 -0
  74. iatoolkit/static/js/chat_filepond.js +85 -0
  75. iatoolkit/static/js/chat_help_content.js +124 -0
  76. iatoolkit/static/js/chat_history_button.js +110 -0
  77. iatoolkit/static/js/chat_logout_button.js +36 -0
  78. iatoolkit/static/js/chat_main.js +401 -0
  79. iatoolkit/static/js/chat_model_selector.js +227 -0
  80. iatoolkit/static/js/chat_onboarding_button.js +103 -0
  81. iatoolkit/static/js/chat_prompt_manager.js +94 -0
  82. iatoolkit/static/js/chat_reload_button.js +38 -0
  83. iatoolkit/static/styles/chat_iatoolkit.css +559 -0
  84. iatoolkit/static/styles/chat_modal.css +133 -0
  85. iatoolkit/static/styles/chat_public.css +135 -0
  86. iatoolkit/static/styles/documents.css +598 -0
  87. iatoolkit/static/styles/landing_page.css +398 -0
  88. iatoolkit/static/styles/llm_output.css +148 -0
  89. iatoolkit/static/styles/onboarding.css +176 -0
  90. iatoolkit/system_prompts/__init__.py +0 -0
  91. iatoolkit/system_prompts/query_main.prompt +30 -23
  92. iatoolkit/system_prompts/sql_rules.prompt +47 -12
  93. iatoolkit/templates/_company_header.html +45 -0
  94. iatoolkit/templates/_login_widget.html +42 -0
  95. iatoolkit/templates/base.html +78 -0
  96. iatoolkit/templates/change_password.html +66 -0
  97. iatoolkit/templates/chat.html +337 -0
  98. iatoolkit/templates/chat_modals.html +185 -0
  99. iatoolkit/templates/error.html +51 -0
  100. iatoolkit/templates/forgot_password.html +51 -0
  101. iatoolkit/templates/onboarding_shell.html +106 -0
  102. iatoolkit/templates/signup.html +79 -0
  103. iatoolkit/views/__init__.py +5 -0
  104. iatoolkit/views/base_login_view.py +96 -0
  105. iatoolkit/views/change_password_view.py +116 -0
  106. iatoolkit/views/chat_view.py +76 -0
  107. iatoolkit/views/embedding_api_view.py +65 -0
  108. iatoolkit/views/forgot_password_view.py +75 -0
  109. iatoolkit/views/help_content_api_view.py +54 -0
  110. iatoolkit/views/history_api_view.py +56 -0
  111. iatoolkit/views/home_view.py +63 -0
  112. iatoolkit/views/init_context_api_view.py +74 -0
  113. iatoolkit/views/llmquery_api_view.py +59 -0
  114. iatoolkit/views/load_company_configuration_api_view.py +49 -0
  115. iatoolkit/views/load_document_api_view.py +65 -0
  116. iatoolkit/views/login_view.py +170 -0
  117. iatoolkit/views/logout_api_view.py +57 -0
  118. iatoolkit/views/profile_api_view.py +46 -0
  119. iatoolkit/views/prompt_api_view.py +37 -0
  120. iatoolkit/views/root_redirect_view.py +22 -0
  121. iatoolkit/views/signup_view.py +100 -0
  122. iatoolkit/views/static_page_view.py +27 -0
  123. iatoolkit/views/user_feedback_api_view.py +60 -0
  124. iatoolkit/views/users_api_view.py +33 -0
  125. iatoolkit/views/verify_user_view.py +60 -0
  126. iatoolkit-0.107.4.dist-info/METADATA +268 -0
  127. iatoolkit-0.107.4.dist-info/RECORD +132 -0
  128. iatoolkit-0.107.4.dist-info/licenses/LICENSE +21 -0
  129. iatoolkit-0.107.4.dist-info/licenses/LICENSE_COMMUNITY.md +15 -0
  130. {iatoolkit-0.3.9.dist-info → iatoolkit-0.107.4.dist-info}/top_level.txt +0 -1
  131. iatoolkit/iatoolkit.py +0 -413
  132. iatoolkit/system_prompts/arquitectura.prompt +0 -32
  133. iatoolkit-0.3.9.dist-info/METADATA +0 -252
  134. iatoolkit-0.3.9.dist-info/RECORD +0 -32
  135. services/__init__.py +0 -5
  136. services/api_service.py +0 -75
  137. services/dispatcher_service.py +0 -351
  138. services/excel_service.py +0 -98
  139. services/history_service.py +0 -45
  140. services/jwt_service.py +0 -91
  141. services/load_documents_service.py +0 -212
  142. services/mail_service.py +0 -62
  143. services/prompt_manager_service.py +0 -172
  144. services/query_service.py +0 -334
  145. services/search_service.py +0 -32
  146. services/sql_service.py +0 -42
  147. services/tasks_service.py +0 -188
  148. services/user_feedback_service.py +0 -67
  149. services/user_session_context_service.py +0 -85
  150. {iatoolkit-0.3.9.dist-info → iatoolkit-0.107.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,278 @@
1
+ # deepseek_adapter.py
2
+ # Copyright (c) 2024 Fernando Libedinsky
3
+ # Product: IAToolkit
4
+ #
5
+ # IAToolkit is open source software.
6
+
7
+ import logging
8
+ from typing import Dict, List, Optional, Any
9
+
10
+ from iatoolkit.infra.llm_response import LLMResponse, ToolCall, Usage
11
+ from iatoolkit.common.exceptions import IAToolkitException
12
+ import json
13
+
14
+ class DeepseekAdapter:
15
+ """
16
+ Adapter for DeepSeek using the OpenAI-compatible Chat Completions API.
17
+ It translates IAToolkit's common request/response format into
18
+ DeepSeek chat.completions calls.
19
+ """
20
+
21
+ def __init__(self, deepseek_client):
22
+ # deepseek_client is an OpenAI client configured with base_url="https://api.deepseek.com"
23
+ self.client = deepseek_client
24
+
25
+ # ------------------------------------------------------------------
26
+ # Public entry point
27
+ # ------------------------------------------------------------------
28
+
29
+ def create_response(self, model: str, input: List[Dict], **kwargs) -> LLMResponse:
30
+ """
31
+ Entry point called by LLMProxy.
32
+
33
+ :param model: DeepSeek model name (e.g. "deepseek-chat").
34
+ :param input: Common IAToolkit input list. It may contain:
35
+ - normal messages: {"role": "...", "content": "..."}
36
+ - function outputs: {"type": "function_call_output",
37
+ "call_id": "...", "output": "..."}
38
+ :param kwargs: extra options (tools, tool_choice, context_history, etc.).
39
+ """
40
+ tools = kwargs.get("tools") or []
41
+ tool_choice = kwargs.get("tool_choice", "auto")
42
+ context_history = kwargs.get("context_history") or []
43
+
44
+ try:
45
+ # 1) Build messages from history (if any)
46
+ messages: List[Dict[str, Any]] = []
47
+ if context_history:
48
+ history_messages = self._build_messages_from_input(context_history)
49
+ messages.extend(history_messages)
50
+
51
+ # 2) Append current turn messages
52
+ current_messages = self._build_messages_from_input(input)
53
+ messages.extend(current_messages)
54
+
55
+ # Detect if this input already contains function_call_output items.
56
+ # That means we are in the "second phase" after executing tools.
57
+ has_function_outputs = any(
58
+ item.get("type") == "function_call_output" for item in input
59
+ )
60
+
61
+ # 3) Build the tools payload
62
+ tools_payload = self._build_tools_payload(tools)
63
+
64
+ # If we already have function_call_output messages and the caller did not force
65
+ # a specific tool_choice (e.g. "required" for SQL retry), we disable tools and
66
+ # tool_choice to avoid infinite tool-calling loops (especially with iat_sql_query).
67
+ if has_function_outputs and tool_choice == "auto":
68
+ logging.debug(
69
+ "[DeepseekAdapter] Detected function_call_output in input; "
70
+ "disabling tools and tool_choice to avoid tool loop."
71
+ )
72
+ tools_payload = None
73
+ tool_choice = None
74
+
75
+ logging.debug(f"[DeepseekAdapter] messages={messages}")
76
+ logging.debug(f"[DeepseekAdapter] tools={tools_payload}, tool_choice={tool_choice}")
77
+
78
+ # Build kwargs for API call, skipping empty parameters
79
+ call_kwargs: Dict[str, Any] = {
80
+ "model": model,
81
+ "messages": messages,
82
+ }
83
+ if tools_payload:
84
+ call_kwargs["tools"] = tools_payload
85
+ if tool_choice:
86
+ call_kwargs["tool_choice"] = tool_choice
87
+
88
+ logging.debug(f"[DeepseekAdapter] Calling DeepSeek chat.completions API...: {json.dumps(messages, indent=2)}")
89
+ response = self.client.chat.completions.create(**call_kwargs)
90
+
91
+ return self._map_deepseek_chat_response(response)
92
+
93
+ except IAToolkitException:
94
+ # Re-raise IAToolkit exceptions as is
95
+ raise
96
+ except Exception as ex:
97
+ logging.exception("Unexpected error calling DeepSeek")
98
+ raise IAToolkitException(
99
+ IAToolkitException.ErrorType.LLM_ERROR,
100
+ f"DeepSeek error: {ex}"
101
+ ) from ex
102
+
103
+ # ------------------------------------------------------------------
104
+ # Helpers to build the request
105
+ # ------------------------------------------------------------------
106
+
107
+ def _build_messages_from_input(self, input_items: List[Dict]) -> List[Dict]:
108
+ """
109
+ Transform IAToolkit 'input' items into ChatCompletion 'messages'.
110
+
111
+ We handle:
112
+ - Standard messages with 'role' and 'content'.
113
+ - function_call_output items by converting them into assistant messages
114
+ containing the tool result, so the model can use them to answer.
115
+ """
116
+ messages: List[Dict[str, Any]] = []
117
+
118
+ for item in input_items:
119
+ # Tool call outputs are mapped to assistant messages with the tool result.
120
+ if item.get("type") == "function_call_output":
121
+ output = item.get("output", "")
122
+ if not output:
123
+ logging.warning(
124
+ "[DeepseekAdapter] function_call_output item without 'output': %s",
125
+ item
126
+ )
127
+ continue
128
+
129
+ messages.append(
130
+ {
131
+ "role": "user",
132
+ "content": f"Tool result:\n{output}",
133
+ }
134
+ )
135
+ continue
136
+
137
+ role = item.get("role")
138
+ content = item.get("content")
139
+
140
+ # Skip tool-role messages completely for DeepSeek
141
+ if role == "tool":
142
+ logging.warning(f"[DeepseekAdapter] Skipping tool-role message: {item}")
143
+ continue
144
+
145
+ if not role:
146
+ logging.warning(f"[DeepseekAdapter] Skipping message without role: {item}")
147
+ continue
148
+
149
+ messages.append({"role": role, "content": content})
150
+
151
+ return messages
152
+
153
+ def _build_tools_payload(self, tools: List[Dict]) -> Optional[List[Dict]]:
154
+ """
155
+ Transform IAToolkit tool definitions into DeepSeek/OpenAI chat tools format.
156
+
157
+ Expected internal tool format:
158
+ {
159
+ "type": "function",
160
+ "name": ...,
161
+ "description": ...,
162
+ "parameters": {...},
163
+ "strict": True/False
164
+ }
165
+ Or already in OpenAI tools format with "function" key.
166
+ """
167
+ if not tools:
168
+ return None
169
+
170
+ tools_payload: List[Dict[str, Any]] = []
171
+
172
+ for tool in tools:
173
+ # If it's already in OpenAI 'function' format, reuse it
174
+ if "function" in tool:
175
+ func_def = tool["function"]
176
+ else:
177
+ # Build function definition from flattened structure
178
+ func_def = {
179
+ "name": tool.get("name"),
180
+ "description": tool.get("description", ""),
181
+ "parameters": tool.get("parameters", {}) or {},
182
+ }
183
+
184
+ # Ensure parameters is a dict
185
+ if "parameters" in func_def and not isinstance(func_def["parameters"], dict):
186
+ logging.warning(
187
+ "Tool parameters must be a dict; got %s",
188
+ type(func_def["parameters"])
189
+ )
190
+ func_def["parameters"] = {}
191
+
192
+ ds_tool: Dict[str, Any] = {
193
+ "type": tool.get("type", "function"),
194
+ "function": func_def,
195
+ }
196
+
197
+ if tool.get("strict") is True:
198
+ ds_tool["strict"] = True
199
+
200
+ tools_payload.append(ds_tool)
201
+
202
+ return tools_payload or None
203
+
204
+ # ------------------------------------------------------------------
205
+ # Mapping DeepSeek response -> LLMResponse
206
+ # ------------------------------------------------------------------
207
+
208
+ def _map_deepseek_chat_response(self, response: Any) -> LLMResponse:
209
+ """
210
+ Map DeepSeek Chat Completion response to our common LLMResponse.
211
+ Handles both plain assistant messages and tool_calls.
212
+ """
213
+ # We only look at the first choice
214
+ if not response.choices:
215
+ raise IAToolkitException(
216
+ IAToolkitException.ErrorType.LLM_ERROR,
217
+ "DeepSeek response has no choices."
218
+ )
219
+
220
+ choice = response.choices[0]
221
+ message = choice.message
222
+
223
+ # Usage mapping
224
+ usage = Usage(
225
+ input_tokens=getattr(getattr(response, "usage", None), "prompt_tokens", 0) or 0,
226
+ output_tokens=getattr(getattr(response, "usage", None), "completion_tokens", 0) or 0,
227
+ total_tokens=getattr(getattr(response, "usage", None), "total_tokens", 0) or 0,
228
+ )
229
+
230
+ # Capture reasoning content (specific to deepseek-reasoner)
231
+ reasoning_content = getattr(message, "reasoning_content", "") or ""
232
+
233
+ # If the model produced tool calls, fills this list
234
+ tool_calls_out: List[ToolCall] = []
235
+
236
+ tool_calls = getattr(message, "tool_calls", None) or []
237
+ if not tool_calls:
238
+ # No tool calls: standard assistant message
239
+ output_text = getattr(message, "content", "") or ""
240
+ status = "completed"
241
+
242
+ else:
243
+ logging.debug(f"[DeepSeek] RAW tool_calls: {tool_calls}")
244
+
245
+ for tc in tool_calls:
246
+ func = getattr(tc, "function", None)
247
+ if not func:
248
+ continue
249
+
250
+ name = getattr(func, "name", "")
251
+ arguments = getattr(func, "arguments", "") or "{}"
252
+
253
+ # DeepSeek/OpenAI return arguments as JSON string
254
+ logging.debug(
255
+ f"[DeepSeek] ToolCall generated -> id={getattr(tc, 'id', '')} "
256
+ f"name={name} arguments_raw={arguments}"
257
+ )
258
+ tool_calls_out.append(
259
+ ToolCall(
260
+ call_id=getattr(tc, "id", ""),
261
+ type="function_call",
262
+ name=name,
263
+ arguments=arguments,
264
+ )
265
+ )
266
+
267
+ status = "tool_calls"
268
+ output_text = "" # caller will inspect tool_calls in .output
269
+
270
+ return LLMResponse(
271
+ id=getattr(response, "id", "deepseek-unknown"),
272
+ model=getattr(response, "model", "deepseek-unknown"),
273
+ status=status,
274
+ output_text=output_text,
275
+ output=tool_calls_out,
276
+ usage=usage,
277
+ reasoning_content=reasoning_content
278
+ )
@@ -0,0 +1,350 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from iatoolkit.infra.llm_response import LLMResponse, ToolCall, Usage
7
+ from typing import Dict, List, Optional
8
+ from google.generativeai.types import HarmCategory, HarmBlockThreshold
9
+ from google.protobuf.json_format import MessageToDict
10
+ from iatoolkit.common.exceptions import IAToolkitException
11
+ import logging
12
+ import json
13
+ import uuid
14
+
15
+ class GeminiAdapter:
16
+
17
+ def __init__(self, gemini_client):
18
+ self.client = gemini_client
19
+
20
+ # security configuration - allow content that might be blocked by default
21
+ self.safety_settings = {
22
+ HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
23
+ HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
24
+ HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
25
+ HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
26
+ }
27
+
28
+ def create_response(self,
29
+ model: str,
30
+ input: List[Dict],
31
+ previous_response_id: Optional[str] = None,
32
+ context_history: Optional[List[Dict]] = None,
33
+ tools: Optional[List[Dict]] = None,
34
+ text: Optional[Dict] = None,
35
+ reasoning: Optional[Dict] = None,
36
+ tool_choice: str = "auto",
37
+ ) -> LLMResponse:
38
+ try:
39
+ # init the model with the configured client
40
+ gemini_model = self.client.GenerativeModel(
41
+ model_name=self._map_model_name(model),
42
+ safety_settings=self.safety_settings
43
+ )
44
+
45
+ # prepare the content for gemini
46
+ if context_history:
47
+ # concat the history with the current input
48
+ contents = self._prepare_gemini_contents(context_history + input)
49
+ else:
50
+ contents = self._prepare_gemini_contents(input)
51
+
52
+ # prepare tools
53
+ gemini_tools = self._prepare_gemini_tools(tools) if tools else None
54
+
55
+ # config generation
56
+ generation_config = self._prepare_generation_config(text, tool_choice)
57
+
58
+ # call gemini
59
+ if gemini_tools:
60
+ # with tools
61
+ response = gemini_model.generate_content(
62
+ contents,
63
+ tools=gemini_tools,
64
+ generation_config=generation_config
65
+ )
66
+ else:
67
+ # without tools
68
+ response = gemini_model.generate_content(
69
+ contents,
70
+ generation_config=generation_config
71
+ )
72
+
73
+ # map the answer to a common structure
74
+ llm_response = self._map_gemini_response(response, model)
75
+
76
+ # add the model answer to the history
77
+ if context_history and llm_response.output_text:
78
+ context_history.append(
79
+ {
80
+ 'role': 'assistant',
81
+ 'context': llm_response.output_text
82
+ }
83
+ )
84
+
85
+ return llm_response
86
+
87
+ except Exception as e:
88
+ error_message = f"Error calling Gemini API: {str(e)}"
89
+ logging.error(error_message)
90
+
91
+ # handle gemini specific errors
92
+ if "quota" in str(e).lower():
93
+ error_message = "Se ha excedido la cuota de la API de Gemini"
94
+ elif "blocked" in str(e).lower():
95
+ error_message = "El contenido fue bloqueado por las políticas de seguridad de Gemini"
96
+ elif "token" in str(e).lower():
97
+ error_message = "Tu consulta supera el límite de contexto de Gemini"
98
+
99
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
100
+
101
+ def _map_model_name(self, model: str) -> str:
102
+ model_mapping = {
103
+ "gemini-pro": "gemini-2.5-pro",
104
+ "gemini": "gemini-2.5-pro",
105
+ "gemini-1.5": "gemini-2.5-pro",
106
+ "gemini-flash": "gemini-1.5-flash",
107
+ "gemini-2.0": "gemini-2.0-flash-exp"
108
+ }
109
+ return model_mapping.get(model.lower(), model)
110
+
111
+ def _prepare_gemini_contents(self, input: List[Dict]) -> List[Dict]:
112
+ # convert input messages to Gemini format
113
+ gemini_contents = []
114
+
115
+ for message in input:
116
+ if message.get("role") == "system":
117
+ gemini_contents.append({
118
+ "role": "user",
119
+ "parts": [{"text": f"[INSTRUCCIONES DEL SISTEMA]\n{message.get('content', '')}"}]
120
+ })
121
+ elif message.get("role") == "user":
122
+ gemini_contents.append({
123
+ "role": "user",
124
+ "parts": [{"text": message.get("content", "")}]
125
+ })
126
+ elif message.get("type") == "function_call_output":
127
+ gemini_contents.append({
128
+ "role": "function",
129
+ "parts": [{
130
+ "function_response": {
131
+ "name": "tool_result",
132
+ "response": {"output": message.get("output", "")}
133
+ }
134
+ }]
135
+ })
136
+
137
+ return gemini_contents
138
+
139
+ def _prepare_gemini_tools(self, tools: List[Dict]) -> List[Dict]:
140
+ # convert tools to Gemini format
141
+ if not tools:
142
+ return None
143
+
144
+ function_declarations = []
145
+ for i, tool in enumerate(tools):
146
+ # Verificar estructura básica
147
+ tool_type = tool.get("type")
148
+
149
+ if tool_type != "function":
150
+ logging.warning(f"Herramienta {i} no es de tipo 'function': {tool_type}")
151
+ continue
152
+
153
+ # Extraer datos de la herramienta (estructura plana)
154
+ function_name = tool.get("name")
155
+ function_description = tool.get("description", "")
156
+ function_parameters = tool.get("parameters", {})
157
+
158
+ # Verificar si el nombre existe y no está vacío
159
+ if not function_name or not isinstance(function_name, str) or not function_name.strip():
160
+ logging.error(f"PROBLEMA: Herramienta {i} sin nombre válido")
161
+ continue
162
+
163
+ # Preparar la declaración de función para Gemini
164
+ gemini_function = {
165
+ "name": function_name,
166
+ "description": function_description,
167
+ }
168
+
169
+ # Agregar parámetros si existen y limpiar campos específicos de OpenAI
170
+ if function_parameters:
171
+ clean_parameters = self._clean_openai_specific_fields(function_parameters)
172
+ gemini_function["parameters"] = clean_parameters
173
+
174
+ function_declarations.append(gemini_function)
175
+
176
+ if function_declarations:
177
+ final_tools = [{
178
+ "function_declarations": function_declarations
179
+ }]
180
+
181
+ # Log de la estructura final para debug
182
+ # logging.info("Estructura final de herramientas para Gemini:")
183
+ # logging.info(f"{json.dumps(final_tools, indent=2)}")
184
+
185
+ return final_tools
186
+
187
+ return None
188
+
189
+
190
+ def _clean_openai_specific_fields(self, parameters: Dict) -> Dict:
191
+ """Limpiar campos específicos de OpenAI que Gemini no entiende"""
192
+ clean_params = {}
193
+
194
+ # Campos permitidos por Gemini según su Schema protobuf
195
+ # Estos son los únicos campos que Gemini acepta en sus esquemas
196
+ allowed_fields = {
197
+ "type", # Tipo de datos: string, number, object, array, boolean
198
+ "properties", # Para objetos: define las propiedades
199
+ "required", # Array de propiedades requeridas
200
+ "items", # Para arrays: define el tipo de elementos
201
+ "description", # Descripción del campo
202
+ "enum", # Lista de valores permitidos
203
+ # Gemini NO soporta estos campos comunes de JSON Schema:
204
+ # "pattern", "format", "minimum", "maximum", "minItems", "maxItems",
205
+ # "minLength", "maxLength", "additionalProperties", "strict"
206
+ }
207
+
208
+ for key, value in parameters.items():
209
+ if key in allowed_fields:
210
+ if key == "properties" and isinstance(value, dict):
211
+ # Limpiar recursivamente las propiedades
212
+ clean_props = {}
213
+ for prop_name, prop_def in value.items():
214
+ if isinstance(prop_def, dict):
215
+ clean_props[prop_name] = self._clean_openai_specific_fields(prop_def)
216
+ else:
217
+ clean_props[prop_name] = prop_def
218
+ clean_params[key] = clean_props
219
+ elif key == "items" and isinstance(value, dict):
220
+ # Limpiar recursivamente los items de array
221
+ clean_params[key] = self._clean_openai_specific_fields(value)
222
+ else:
223
+ clean_params[key] = value
224
+ else:
225
+ logging.debug(f"Campo '{key}' removido (no soportado por Gemini)")
226
+
227
+ return clean_params
228
+
229
+ def _prepare_generation_config(self, text: Optional[Dict], tool_choice: str) -> Dict:
230
+ """Preparar configuración de generación para Gemini"""
231
+ config = {"candidate_count": 1}
232
+
233
+ if text:
234
+ if "temperature" in text:
235
+ config["temperature"] = float(text["temperature"])
236
+ if "max_tokens" in text:
237
+ config["max_output_tokens"] = int(text["max_tokens"])
238
+ if "top_p" in text:
239
+ config["top_p"] = float(text["top_p"])
240
+
241
+ return config
242
+
243
+ def _map_gemini_response(self, gemini_response, model: str) -> LLMResponse:
244
+ """Mapear respuesta de Gemini a estructura común"""
245
+ response_id = str(uuid.uuid4())
246
+ output_text = ""
247
+ tool_calls = []
248
+
249
+ if gemini_response.candidates and len(gemini_response.candidates) > 0:
250
+ candidate = gemini_response.candidates[0]
251
+
252
+ for part in candidate.content.parts:
253
+ if hasattr(part, 'text') and part.text:
254
+ output_text += part.text
255
+ elif hasattr(part, 'function_call') and part.function_call:
256
+ func_call = part.function_call
257
+ tool_calls.append(ToolCall(
258
+ call_id=f"call_{uuid.uuid4().hex[:8]}",
259
+ type="function_call",
260
+ name=func_call.name,
261
+ arguments=json.dumps(MessageToDict(func_call._pb).get('args', {}))
262
+ ))
263
+
264
+ # Determinar status
265
+ status = "completed"
266
+ if gemini_response.candidates:
267
+ candidate = gemini_response.candidates[0]
268
+ if hasattr(candidate, 'finish_reason'):
269
+ # Manejar finish_reason tanto como objeto con .name como entero/enum directo
270
+ finish_reason = candidate.finish_reason
271
+
272
+ # Si finish_reason tiene un atributo .name, usarlo
273
+ if hasattr(finish_reason, 'name'):
274
+ finish_reason_name = finish_reason.name
275
+ else:
276
+ # Si es un entero o enum directo, convertirlo a string
277
+ finish_reason_name = str(finish_reason)
278
+
279
+ if finish_reason_name in ["SAFETY", "RECITATION", "3", "4"]: # Agregar valores numéricos también
280
+ status = "blocked"
281
+ elif finish_reason_name in ["MAX_TOKENS", "LENGTH", "2"]: # Agregar valores numéricos también
282
+ status = "length_exceeded"
283
+
284
+ # Calcular usage de tokens
285
+ usage = self._extract_usage_metadata(gemini_response)
286
+
287
+ # Estimación básica si no hay datos de usage
288
+ if usage.total_tokens == 0:
289
+ estimated_output_tokens = len(output_text) // 4
290
+ usage = Usage(
291
+ input_tokens=0,
292
+ output_tokens=estimated_output_tokens,
293
+ total_tokens=estimated_output_tokens
294
+ )
295
+
296
+ return LLMResponse(
297
+ id=response_id,
298
+ model=model,
299
+ status=status,
300
+ output_text=output_text,
301
+ output=tool_calls,
302
+ usage=usage
303
+ )
304
+
305
+ def _extract_usage_metadata(self, gemini_response) -> Usage:
306
+ """Extraer información de uso de tokens de manera segura"""
307
+ input_tokens = 0
308
+ output_tokens = 0
309
+ total_tokens = 0
310
+
311
+ try:
312
+ # Verificar si existe usage_metadata
313
+ if hasattr(gemini_response, 'usage_metadata') and gemini_response.usage_metadata:
314
+ usage_metadata = gemini_response.usage_metadata
315
+
316
+ # Acceder a los atributos directamente, no con .get()
317
+ if hasattr(usage_metadata, 'prompt_token_count'):
318
+ input_tokens = usage_metadata.prompt_token_count
319
+ if hasattr(usage_metadata, 'candidates_token_count'):
320
+ output_tokens = usage_metadata.candidates_token_count
321
+ if hasattr(usage_metadata, 'total_token_count'):
322
+ total_tokens = usage_metadata.total_token_count
323
+
324
+ except Exception as e:
325
+ logging.warning(f"No se pudo extraer usage_metadata de Gemini: {e}")
326
+
327
+ # Si no hay datos de usage o son cero, hacer estimación básica
328
+ if total_tokens == 0 and output_tokens == 0:
329
+ # Obtener texto de salida para estimación
330
+ output_text = ""
331
+ if (hasattr(gemini_response, 'candidates') and
332
+ gemini_response.candidates and
333
+ len(gemini_response.candidates) > 0):
334
+
335
+ candidate = gemini_response.candidates[0]
336
+ if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts'):
337
+ for part in candidate.content.parts:
338
+ if hasattr(part, 'text') and part.text:
339
+ output_text += part.text
340
+
341
+ # Estimación básica (4 caracteres por token aproximadamente)
342
+ estimated_output_tokens = len(output_text) // 4 if output_text else 0
343
+ output_tokens = estimated_output_tokens
344
+ total_tokens = estimated_output_tokens
345
+
346
+ return Usage(
347
+ input_tokens=input_tokens,
348
+ output_tokens=output_tokens,
349
+ total_tokens=total_tokens
350
+ )