iatoolkit 0.7.4__py3-none-any.whl → 0.7.6__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 (57) hide show
  1. common/__init__.py +0 -0
  2. common/auth.py +200 -0
  3. common/exceptions.py +46 -0
  4. common/routes.py +86 -0
  5. common/session_manager.py +25 -0
  6. common/util.py +358 -0
  7. iatoolkit/iatoolkit.py +3 -3
  8. {iatoolkit-0.7.4.dist-info → iatoolkit-0.7.6.dist-info}/METADATA +1 -1
  9. iatoolkit-0.7.6.dist-info/RECORD +80 -0
  10. iatoolkit-0.7.6.dist-info/top_level.txt +6 -0
  11. infra/__init__.py +5 -0
  12. infra/call_service.py +140 -0
  13. infra/connectors/__init__.py +5 -0
  14. infra/connectors/file_connector.py +17 -0
  15. infra/connectors/file_connector_factory.py +57 -0
  16. infra/connectors/google_cloud_storage_connector.py +53 -0
  17. infra/connectors/google_drive_connector.py +68 -0
  18. infra/connectors/local_file_connector.py +46 -0
  19. infra/connectors/s3_connector.py +33 -0
  20. infra/gemini_adapter.py +356 -0
  21. infra/google_chat_app.py +57 -0
  22. infra/llm_client.py +430 -0
  23. infra/llm_proxy.py +139 -0
  24. infra/llm_response.py +40 -0
  25. infra/mail_app.py +145 -0
  26. infra/openai_adapter.py +90 -0
  27. infra/redis_session_manager.py +76 -0
  28. repositories/__init__.py +5 -0
  29. repositories/database_manager.py +95 -0
  30. repositories/document_repo.py +33 -0
  31. repositories/llm_query_repo.py +91 -0
  32. repositories/models.py +309 -0
  33. repositories/profile_repo.py +118 -0
  34. repositories/tasks_repo.py +52 -0
  35. repositories/vs_repo.py +139 -0
  36. views/__init__.py +5 -0
  37. views/change_password_view.py +91 -0
  38. views/chat_token_request_view.py +98 -0
  39. views/chat_view.py +51 -0
  40. views/download_file_view.py +58 -0
  41. views/external_chat_login_view.py +88 -0
  42. views/external_login_view.py +40 -0
  43. views/file_store_view.py +58 -0
  44. views/forgot_password_view.py +64 -0
  45. views/history_view.py +57 -0
  46. views/home_view.py +34 -0
  47. views/llmquery_view.py +65 -0
  48. views/login_view.py +60 -0
  49. views/prompt_view.py +37 -0
  50. views/signup_view.py +87 -0
  51. views/tasks_review_view.py +83 -0
  52. views/tasks_view.py +98 -0
  53. views/user_feedback_view.py +74 -0
  54. views/verify_user_view.py +55 -0
  55. iatoolkit-0.7.4.dist-info/RECORD +0 -30
  56. iatoolkit-0.7.4.dist-info/top_level.txt +0 -2
  57. {iatoolkit-0.7.4.dist-info → iatoolkit-0.7.6.dist-info}/WHEEL +0 -0
infra/llm_client.py ADDED
@@ -0,0 +1,430 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from infra.llm_proxy import LLMProxy
7
+ from repositories.models import Company, LLMQuery
8
+ from repositories.llm_query_repo import LLMQueryRepo
9
+ from sqlalchemy.exc import SQLAlchemyError, OperationalError
10
+ from common.util import Utility
11
+ from injector import inject
12
+ import time
13
+ import markdown2
14
+ import os
15
+ import logging
16
+ import json
17
+ from common.exceptions import IAToolkitException
18
+ import threading
19
+ import re
20
+ import tiktoken
21
+ from typing import Dict, Optional, List
22
+ from services.dispatcher_service import Dispatcher
23
+
24
+
25
+ class llmClient:
26
+ _llm_clients_cache = {} # class attribute, for the clients cache
27
+ _clients_cache_lock = threading.Lock() # secure lock cache access
28
+
29
+ @inject
30
+ def __init__(self,
31
+ llmquery_repo: LLMQueryRepo,
32
+ llm_proxy_factory: LLMProxy,
33
+ util: Utility
34
+ ):
35
+ self.llmquery_repo = llmquery_repo
36
+ self.llm_proxy_factory = llm_proxy_factory
37
+ self.util = util
38
+ self._dispatcher = None # Cache for the lazy-loaded dispatcher
39
+
40
+ # get the model from the environment variable
41
+ self.model = os.getenv("LLM_MODEL", "")
42
+ if not self.model:
43
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
44
+ "La variable de entorno 'LLM_MODEL' no está configurada.")
45
+
46
+ # library for counting tokens
47
+ self.encoding = tiktoken.encoding_for_model("gpt-4o")
48
+
49
+ # max number of sql retries
50
+ self.MAX_SQL_RETRIES = 1
51
+
52
+ @property
53
+ def dispatcher(self) -> 'Dispatcher':
54
+ """Lazy-loads and returns the Dispatcher instance."""
55
+ if self._dispatcher is None:
56
+ # Import what you need, right when you need it.
57
+ from iatoolkit import current_iatoolkit
58
+ from services.dispatcher_service import Dispatcher
59
+ # Use the global context proxy to get the injector, then get the service
60
+ self._dispatcher = current_iatoolkit().get_injector().get(Dispatcher)
61
+ return self._dispatcher
62
+
63
+
64
+ def invoke(self,
65
+ company: Company,
66
+ user_identifier: str,
67
+ previous_response_id: str,
68
+ question: str,
69
+ context: str,
70
+ tools: list[dict],
71
+ text: dict,
72
+ context_history: Optional[List[Dict]] = None,
73
+ ) -> dict:
74
+
75
+ f_calls = [] # keep track of the function calls executed by the LLM
76
+ f_call_time = 0
77
+ response = None
78
+ sql_retry_count = 0
79
+ force_tool_name = None
80
+ reasoning = {}
81
+
82
+ if 'gpt-5' in self.model:
83
+ text['verbosity'] = "low"
84
+ reasoning = {"effort": 'minimal'}
85
+
86
+ try:
87
+ start_time = time.time()
88
+ logging.info(f"calling llm model '{self.model}' with {self.count_tokens(context)} tokens...")
89
+
90
+ # get the proxy for the company
91
+ llm_proxy = self.llm_proxy_factory.create_for_company(company)
92
+
93
+ # here is the first call to the LLM
94
+ try:
95
+ input_messages = [{
96
+ "role": "user",
97
+ "content": context
98
+ }]
99
+
100
+ response = llm_proxy.create_response(
101
+ model=self.model,
102
+ previous_response_id=previous_response_id,
103
+ context_history=context_history,
104
+ input=input_messages,
105
+ tools=tools,
106
+ text=text,
107
+ reasoning=reasoning,
108
+ )
109
+ stats = self.get_stats(response)
110
+
111
+ except Exception as e:
112
+ # if the llm api fails: context, api-key, etc
113
+ # log the error and envolve in our own exception
114
+ error_message = f"Error calling LLM API: {str(e)}"
115
+ logging.error(error_message)
116
+
117
+ # in case of context error
118
+ if "context_length_exceeded" in str(e):
119
+ error_message = 'Tu consulta supera el limite de contexto, sale e ingresa de nuevo a IAToolkit'
120
+
121
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
122
+
123
+ while True:
124
+ # check if there are function calls to execute
125
+ function_calls = False
126
+ stats_fcall = {}
127
+ for tool_call in response.output:
128
+ if tool_call.type != "function_call":
129
+ continue
130
+
131
+ # execute the function call through the dispatcher
132
+ fcall_time = time.time()
133
+ function_name = tool_call.name
134
+ args = json.loads(tool_call.arguments)
135
+ logging.info(f"start execution fcall: {function_name}")
136
+ try:
137
+ result = self.dispatcher.dispatch(
138
+ company_name=company.short_name,
139
+ action=function_name,
140
+ **args
141
+ )
142
+ force_tool_name = None
143
+ except IAToolkitException as e:
144
+ if (e.error_type == IAToolkitException.ErrorType.DATABASE_ERROR and
145
+ sql_retry_count < self.MAX_SQL_RETRIES):
146
+ sql_retry_count += 1
147
+ sql_query_with_error = args.get('query', 'No se pudo extraer la consulta.')
148
+ original_db_error = str(e.__cause__) if e.__cause__ else str(e)
149
+
150
+ logging.warning(
151
+ f"Error de SQL capturado, intentando corregir con el LLM (Intento {sql_retry_count}/{self.MAX_SQL_RETRIES}).")
152
+ result = self._create_sql_retry_prompt(function_name, sql_query_with_error, original_db_error)
153
+
154
+ # force the next call to be this function
155
+ force_tool_name = function_name
156
+ else:
157
+ error_message = f"Error en dispatch para '{function_name}' tras {sql_retry_count} reintentos: {str(e)}"
158
+ raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
159
+ except Exception as e:
160
+ error_message = f"Dispatch error en {function_name} con args {args} -******- {str(e)}"
161
+ raise IAToolkitException(IAToolkitException.ErrorType.CALL_ERROR, error_message)
162
+
163
+ # add the return value into the list of messages
164
+ input_messages.append({
165
+ "type": "function_call_output",
166
+ "call_id": tool_call.call_id,
167
+ "output": str(result)
168
+ })
169
+ function_calls = True
170
+
171
+ # log the function call parameters and execution time in secs
172
+ elapsed = time.time() - fcall_time
173
+ f_call_identity = {function_name:args, 'time': f'{elapsed:.1f}' }
174
+ f_calls.append(f_call_identity)
175
+ f_call_time += elapsed
176
+
177
+ logging.info(f"end execution {function_name} in {elapsed:.1f} secs.")
178
+
179
+ if not function_calls:
180
+ break # no function call answer to send back to llm
181
+
182
+ # send results back to the LLM
183
+ tool_choice_value = "auto"
184
+ if force_tool_name:
185
+ tool_choice_value = "required"
186
+
187
+ response = llm_proxy.create_response(
188
+ model=self.model,
189
+ input=input_messages,
190
+ previous_response_id=response.id,
191
+ context_history=context_history,
192
+ reasoning=reasoning,
193
+ tool_choice=tool_choice_value,
194
+ tools=tools,
195
+ text=text
196
+ )
197
+ stats_fcall = self.add_stats(stats_fcall, self.get_stats(response))
198
+
199
+ # save the statistices
200
+ stats['response_time']=int(time.time() - start_time)
201
+ stats['sql_retry_count'] = sql_retry_count
202
+ stats['model'] = response.model
203
+
204
+ # decode the LLM response
205
+ decoded_response = self.decode_response(response)
206
+
207
+ # save the query and response
208
+ query = LLMQuery(user_identifier=user_identifier,
209
+ task_id=0,
210
+ company_id=company.id,
211
+ query=question,
212
+ output=decoded_response.get('answer', ''),
213
+ valid_response=decoded_response.get('status', False),
214
+ response=self.serialize_response(response, decoded_response),
215
+ function_calls=f_calls,
216
+ stats=self.add_stats(stats, stats_fcall),
217
+ answer_time=stats['response_time']
218
+ )
219
+ self.llmquery_repo.add_query(query)
220
+ logging.info(f"finish llm call in {int(time.time() - start_time)} secs..")
221
+ if function_calls:
222
+ logging.info(f"time within the function calls {f_call_time:.1f} secs.")
223
+
224
+ return {
225
+ 'valid_response': decoded_response.get('status', False),
226
+ 'answer': self.format_html(decoded_response.get('answer', '')),
227
+ 'stats': stats,
228
+ 'answer_format': decoded_response.get('answer_format', ''),
229
+ 'error_message': decoded_response.get('error_message', ''),
230
+ 'aditional_data': decoded_response.get('aditional_data', {}),
231
+ 'response_id': response.id,
232
+ 'query_id': query.id,
233
+ }
234
+ except SQLAlchemyError as db_error:
235
+ # rollback
236
+ self.llmquery_repo.session.rollback()
237
+ logging.error(f"Error de base de datos: {str(db_error)}")
238
+ raise db_error
239
+ except OperationalError as e:
240
+ logging.error(f"Operational error: {str(e)}")
241
+ raise e
242
+ except Exception as e:
243
+ error_message= str(e)
244
+
245
+ # log the error in the llm_query table
246
+ query = LLMQuery(user_identifier=user_identifier,
247
+ task_id=0,
248
+ company_id=company.id,
249
+ query=question,
250
+ output=error_message,
251
+ response=response.output_text if response else {},
252
+ valid_response=False,
253
+ function_calls=f_calls,
254
+ )
255
+ self.llmquery_repo.add_query(query)
256
+
257
+ # in case of context error
258
+ if "context_length_exceeded" in str(e):
259
+ error_message = 'Tu consulta supera el limite de contexto, sale e ingresa de nuevo a IAToolkit'
260
+ elif "string_above_max_length" in str(e):
261
+ error_message = 'La respuesta es muy larga, trata de filtrar/restringuir tu consulta'
262
+
263
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
264
+
265
+ def set_company_context(self,
266
+ company: Company,
267
+ company_base_context: str,
268
+ model: str = None) -> str:
269
+
270
+ if model:
271
+ self.model = model
272
+ logging.info(f"initializing model '{self.model}' with company context: {self.count_tokens(company_base_context)} tokens...")
273
+
274
+ llm_proxy = self.llm_proxy_factory.create_for_company(company)
275
+ try:
276
+ response = llm_proxy.create_response(
277
+ model=self.model,
278
+ input=[{
279
+ "role": "system",
280
+ "content": company_base_context
281
+ }]
282
+ )
283
+
284
+ except Exception as e:
285
+ error_message = f"Error calling LLM API: {str(e)}"
286
+ logging.error(error_message)
287
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, error_message)
288
+
289
+ return response.id
290
+
291
+ def decode_response(self, response) -> dict:
292
+ message = response.output_text
293
+ decoded_response = {
294
+ "status": False,
295
+ "output_text": message,
296
+ "answer": "",
297
+ "aditional_data": {},
298
+ "answer_format": "",
299
+ "error_message": ""
300
+ }
301
+
302
+ if response.status != 'completed':
303
+ decoded_response[
304
+ 'error_message'] = f'LLM ERROR {response.status}: no se completo tu pregunta, intenta de nuevo ...'
305
+ return decoded_response
306
+
307
+ if isinstance(message, dict):
308
+ if 'answer' not in message or 'aditional_data' not in message:
309
+ decoded_response['error_message'] = 'El llm respondio un diccionario invalido: missing "answer" key'
310
+ return decoded_response
311
+
312
+ decoded_response['status'] = True
313
+ decoded_response['answer'] = message.get('answer', '')
314
+ decoded_response['aditional_data'] = message.get('aditional_data', {})
315
+ decoded_response['answer_format'] = "dict"
316
+ return decoded_response
317
+
318
+ clean_message = re.sub(r'^\s*//.*$', '', message, flags=re.MULTILINE)
319
+
320
+ if not ('```json' in clean_message or clean_message.strip().startswith('{')):
321
+ decoded_response['status'] = True
322
+ decoded_response['answer'] = clean_message
323
+ decoded_response['answer_format'] = "plaintext"
324
+ return decoded_response
325
+
326
+ try:
327
+ # prepare the message for json load
328
+ json_string = clean_message.strip()
329
+ if json_string.startswith('```json'):
330
+ json_string = json_string[7:]
331
+ if json_string.endswith('```'):
332
+ json_string = json_string[:-3]
333
+
334
+ response_dict = json.loads(json_string.strip())
335
+ except Exception as e:
336
+ # --- ESTRATEGIA DE RESPALDO (FALLBACK) CON RESCATE DE DATOS ---
337
+ decoded_response['error_message'] = f'Error decodificando JSON: {str(e)}'
338
+
339
+ # Intenta rescatar el contenido de "answer" con una expresión regular más robusta.
340
+ # Este patrón busca "answer": "..." y captura todo hasta que encuentra "," y "aditional_data".
341
+ # re.DOTALL es crucial para que `.` coincida con los saltos de línea en el HTML.
342
+ match = re.search(r'"answer"\s*:\s*"(.*?)"\s*,\s*"aditional_data"', clean_message, re.DOTALL)
343
+
344
+ if match:
345
+ # ¡Éxito! Se encontró y extrajo el "answer".
346
+ # Se limpia el contenido de escapes JSON para obtener el HTML puro.
347
+ rescued_answer = match.group(1).replace('\\n', '\n').replace('\\"', '"')
348
+
349
+ decoded_response['status'] = True
350
+ decoded_response['answer'] = rescued_answer
351
+ decoded_response['answer_format'] = "plaintext_fallback_rescued"
352
+ else:
353
+ # Si la regex no encuentra nada, usar el texto completo como último recurso.
354
+ decoded_response['status'] = True
355
+ decoded_response['answer'] = clean_message
356
+ decoded_response['answer_format'] = "plaintext_fallback_full"
357
+ else:
358
+ # --- SOLO SE EJECUTA SI EL TRY FUE EXITOSO ---
359
+ if 'answer' not in response_dict or 'aditional_data' not in response_dict:
360
+ decoded_response['error_message'] = f'faltan las claves "answer" o "aditional_data" en el JSON'
361
+
362
+ # fallback
363
+ decoded_response['status'] = True
364
+ decoded_response['answer'] = str(response_dict)
365
+ decoded_response['answer_format'] = "json_fallback"
366
+ else:
367
+ # El diccionario JSON es perfecto.
368
+ decoded_response['status'] = True
369
+ decoded_response['answer'] = response_dict.get('answer', '')
370
+ decoded_response['aditional_data'] = response_dict.get('aditional_data', {})
371
+ decoded_response['answer_format'] = "json_string"
372
+
373
+ return decoded_response
374
+
375
+ def serialize_response(self, response, decoded_response):
376
+ response_dict = {
377
+ "format": decoded_response.get('answer_format', ''),
378
+ "error_message": decoded_response.get('error_message', ''),
379
+ "output": decoded_response.get('output_text', ''),
380
+ "id": response.id,
381
+ "model": response.model,
382
+ "status": response.status,
383
+ }
384
+ return response_dict
385
+
386
+ def get_stats(self, response):
387
+ stats_dict = {
388
+ "input_tokens": response.usage.input_tokens,
389
+ "output_tokens": response.usage.output_tokens,
390
+ "total_tokens": response.usage.total_tokens
391
+ }
392
+ return stats_dict
393
+
394
+ def add_stats(self, stats1: dict, stats2: dict) -> dict:
395
+ stats_dict = {
396
+ "input_tokens": stats1.get('input_tokens', 0) + stats2.get('input_tokens', 0),
397
+ "output_tokens": stats1.get('output_tokens', 0) + stats2.get('output_tokens', 0),
398
+ "total_tokens": stats1.get('total_tokens', 0) + stats2.get('total_tokens', 0),
399
+ }
400
+ return stats_dict
401
+
402
+
403
+ def _create_sql_retry_prompt(self, function_name: str, sql_query: str, db_error: str) -> str:
404
+ return f"""
405
+ ## ERROR DE EJECUCIÓN DE HERRAMIENTA
406
+
407
+ **Estado:** Fallido
408
+ **Herramienta:** `{function_name}`
409
+
410
+ La ejecución de la consulta SQL falló.
411
+
412
+ **Error específico de la base de datos:**
413
+ {db_error}
414
+ **Consulta SQL que causó el error:**
415
+ sql {sql_query}
416
+
417
+ **INSTRUCCIÓN OBLIGATORIA:**
418
+ 1. Analiza el error y corrige la sintaxis de la consulta SQL anterior.
419
+ 2. Llama a la herramienta `{function_name}` **OTRA VEZ**, inmediatamente, con la consulta corregida.
420
+ 3. **NO** respondas al usuario con este mensaje de error. Tu ÚNICA acción debe ser volver a llamar a la herramienta con la solución.
421
+ """
422
+
423
+ def format_html(self, answer: str):
424
+ html_answer = markdown2.markdown(answer).replace("\n", "")
425
+ return html_answer
426
+
427
+ def count_tokens(self, text):
428
+ # Codifica el texto y cuenta la cantidad de tokens
429
+ tokens = self.encoding.encode(text)
430
+ return len(tokens)
infra/llm_proxy.py ADDED
@@ -0,0 +1,139 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from typing import Dict, List, Any
7
+ from abc import ABC, abstractmethod
8
+ from common.util import Utility
9
+ from infra.llm_response import LLMResponse
10
+ from infra.openai_adapter import OpenAIAdapter
11
+ from infra.gemini_adapter import GeminiAdapter
12
+ from common.exceptions import IAToolkitException
13
+ from repositories.models import Company
14
+ from openai import OpenAI
15
+ import google.generativeai as genai
16
+ import os
17
+ import threading
18
+ from enum import Enum
19
+ from injector import inject
20
+
21
+
22
+ class LLMProvider(Enum):
23
+ OPENAI = "openai"
24
+ GEMINI = "gemini"
25
+
26
+
27
+ class LLMAdapter(ABC):
28
+ """common interface for LLM adapters"""
29
+
30
+ @abstractmethod
31
+ def create_response(self, *args, **kwargs) -> LLMResponse:
32
+ pass
33
+
34
+
35
+ class LLMProxy:
36
+ """
37
+ Proxy que enruta las llamadas al adaptador correcto y gestiona la creación
38
+ de los clientes de los proveedores de LLM.
39
+ """
40
+ _clients_cache = {}
41
+ _clients_cache_lock = threading.Lock()
42
+
43
+ @inject
44
+ def __init__(self, util: Utility, openai_client = None, gemini_client = None):
45
+ """
46
+ Inicializa una instancia del proxy. Puede ser una instancia "base" (fábrica)
47
+ o una instancia de "trabajo" con clientes configurados.
48
+ """
49
+ self.util = util
50
+ self.openai_adapter = OpenAIAdapter(openai_client) if openai_client else None
51
+ self.gemini_adapter = GeminiAdapter(gemini_client) if gemini_client else None
52
+
53
+ def create_for_company(self, company: Company) -> 'LLMProxy':
54
+ """
55
+ Crea y configura una nueva instancia de LLMProxy para una empresa específica.
56
+ """
57
+ try:
58
+ openai_client = self._get_llm_connection(company, LLMProvider.OPENAI)
59
+ except IAToolkitException:
60
+ openai_client = None
61
+
62
+ try:
63
+ gemini_client = self._get_llm_connection(company, LLMProvider.GEMINI)
64
+ except IAToolkitException:
65
+ gemini_client = None
66
+
67
+ if not openai_client and not gemini_client:
68
+ raise IAToolkitException(
69
+ IAToolkitException.ErrorType.API_KEY,
70
+ f"La empresa '{company.name}' no tiene configuradas API keys para ningún proveedor LLM."
71
+ )
72
+
73
+ # Devuelve una NUEVA instancia con los clientes configurados
74
+ return LLMProxy(util=self.util, openai_client=openai_client, gemini_client=gemini_client)
75
+
76
+ def create_response(self, model: str, input: List[Dict], **kwargs) -> LLMResponse:
77
+ """Enruta la llamada al adaptador correcto basado en el modelo."""
78
+ # Se asume que esta instancia ya tiene los clientes configurados por `create_for_company`
79
+ if self.util.is_openai_model(model):
80
+ if not self.openai_adapter:
81
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
82
+ f"No se configuró cliente OpenAI, pero se solicitó modelo OpenAI: {model}")
83
+ return self.openai_adapter.create_response(model=model, input=input, **kwargs)
84
+ elif self.util.is_gemini_model(model):
85
+ if not self.gemini_adapter:
86
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
87
+ f"No se configuró cliente Gemini, pero se solicitó modelo Gemini: {model}")
88
+ return self.gemini_adapter.create_response(model=model, input=input, **kwargs)
89
+ else:
90
+ raise IAToolkitException(IAToolkitException.ErrorType.LLM_ERROR, f"Modelo no soportado: {model}")
91
+
92
+ def _get_llm_connection(self, company: Company, provider: LLMProvider) -> Any:
93
+ """Obtiene una conexión de cliente para un proveedor, usando un caché para reutilizarla."""
94
+ cache_key = f"{company.short_name}_{provider.value}"
95
+ client = LLMProxy._clients_cache.get(cache_key)
96
+
97
+ if not client:
98
+ with LLMProxy._clients_cache_lock:
99
+ client = LLMProxy._clients_cache.get(cache_key)
100
+ if not client:
101
+ if provider == LLMProvider.OPENAI:
102
+ client = self._create_openai_client(company)
103
+ elif provider == LLMProvider.GEMINI:
104
+ client = self._create_gemini_client(company)
105
+ else:
106
+ raise IAToolkitException(f"Proveedor no soportado: {provider.value}")
107
+
108
+ if client:
109
+ LLMProxy._clients_cache[cache_key] = client
110
+
111
+ if not client:
112
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY, f"No se pudo crear el cliente para {provider.value}")
113
+
114
+ return client
115
+
116
+ def _create_openai_client(self, company: Company) -> OpenAI:
117
+ """Crea un cliente de OpenAI con la API key."""
118
+ if company.openai_api_key:
119
+ decrypted_api_key = self.util.decrypt_key(company.openai_api_key)
120
+ else:
121
+ decrypted_api_key = os.getenv("OPENAI_API_KEY", '')
122
+ if not decrypted_api_key:
123
+ raise IAToolkitException(IAToolkitException.ErrorType.API_KEY,
124
+ f"La empresa '{company.name}' no tiene API key de OpenAI.")
125
+ return OpenAI(api_key=decrypted_api_key)
126
+
127
+ def _create_gemini_client(self, company: Company) -> Any:
128
+ """Configura y devuelve el cliente de Gemini."""
129
+
130
+ if company.gemini_api_key:
131
+ decrypted_api_key = self.util.decrypt_key(company.gemini_api_key)
132
+ else:
133
+ decrypted_api_key = os.getenv("GEMINI_API_KEY", '')
134
+
135
+ if not decrypted_api_key:
136
+ return None
137
+ genai.configure(api_key=decrypted_api_key)
138
+ return genai
139
+
infra/llm_response.py ADDED
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Dict, List, Any, Optional
8
+
9
+ @dataclass
10
+ class ToolCall:
11
+ """Representa una llamada a herramienta en formato común"""
12
+ call_id: str
13
+ type: str # 'function_call'
14
+ name: str
15
+ arguments: str
16
+
17
+
18
+ @dataclass
19
+ class Usage:
20
+ """Información de uso de tokens en formato común"""
21
+ input_tokens: int
22
+ output_tokens: int
23
+ total_tokens: int
24
+
25
+
26
+ @dataclass
27
+ class LLMResponse:
28
+ """Estructura común para respuestas de diferentes LLMs"""
29
+ id: str
30
+ model: str
31
+ status: str # 'completed', 'failed', etc.
32
+ output_text: str
33
+ output: List[ToolCall] # lista de tool calls
34
+ usage: Usage
35
+
36
+ def __post_init__(self):
37
+ """Asegura que output sea una lista"""
38
+ if self.output is None:
39
+ self.output = []
40
+