janito 2.6.1__py3-none-any.whl → 2.8.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.
Files changed (118) hide show
  1. janito/__init__.py +6 -7
  2. janito/__main__.py +4 -5
  3. janito/_version.py +55 -58
  4. janito/agent/setup_agent.py +308 -241
  5. janito/agent/templates/profiles/{system_prompt_template_software developer.txt.j2 → system_prompt_template_Developer_with_Python_Tools.txt.j2} +43 -39
  6. janito/agent/templates/profiles/system_prompt_template_developer.txt.j2 +3 -12
  7. janito/cli/__init__.py +9 -10
  8. janito/cli/chat_mode/bindings.py +38 -38
  9. janito/cli/chat_mode/chat_entry.py +21 -23
  10. janito/cli/chat_mode/prompt_style.py +22 -25
  11. janito/cli/chat_mode/script_runner.py +158 -154
  12. janito/cli/chat_mode/session.py +80 -35
  13. janito/cli/chat_mode/session_profile_select.py +61 -52
  14. janito/cli/chat_mode/shell/commands/__init__.py +1 -5
  15. janito/cli/chat_mode/shell/commands/_priv_check.py +1 -0
  16. janito/cli/chat_mode/shell/commands/bang.py +10 -3
  17. janito/cli/chat_mode/shell/commands/conversation_restart.py +24 -7
  18. janito/cli/chat_mode/shell/commands/execute.py +22 -7
  19. janito/cli/chat_mode/shell/commands/help.py +4 -1
  20. janito/cli/chat_mode/shell/commands/model.py +13 -5
  21. janito/cli/chat_mode/shell/commands/privileges.py +21 -0
  22. janito/cli/chat_mode/shell/commands/prompt.py +0 -2
  23. janito/cli/chat_mode/shell/commands/read.py +22 -5
  24. janito/cli/chat_mode/shell/commands/tools.py +15 -4
  25. janito/cli/chat_mode/shell/commands/write.py +22 -5
  26. janito/cli/chat_mode/shell/input_history.py +3 -1
  27. janito/cli/chat_mode/shell/session/manager.py +0 -2
  28. janito/cli/chat_mode/toolbar.py +25 -19
  29. janito/cli/cli_commands/list_models.py +1 -1
  30. janito/cli/cli_commands/list_providers.py +1 -0
  31. janito/cli/cli_commands/list_tools.py +35 -7
  32. janito/cli/cli_commands/model_utils.py +5 -3
  33. janito/cli/cli_commands/show_config.py +12 -0
  34. janito/cli/cli_commands/show_system_prompt.py +23 -9
  35. janito/cli/config.py +0 -13
  36. janito/cli/core/getters.py +2 -0
  37. janito/cli/core/runner.py +25 -8
  38. janito/cli/core/setters.py +13 -76
  39. janito/cli/main_cli.py +9 -25
  40. janito/cli/prompt_core.py +19 -18
  41. janito/cli/prompt_setup.py +6 -3
  42. janito/cli/rich_terminal_reporter.py +19 -5
  43. janito/cli/single_shot_mode/handler.py +104 -95
  44. janito/cli/verbose_output.py +5 -1
  45. janito/config_manager.py +4 -0
  46. janito/drivers/azure_openai/driver.py +27 -30
  47. janito/drivers/driver_registry.py +27 -27
  48. janito/drivers/openai/driver.py +452 -436
  49. janito/formatting_token.py +12 -4
  50. janito/llm/agent.py +15 -6
  51. janito/llm/driver.py +1 -0
  52. janito/provider_registry.py +139 -178
  53. janito/providers/__init__.py +2 -0
  54. janito/providers/anthropic/model_info.py +40 -41
  55. janito/providers/anthropic/provider.py +75 -80
  56. janito/providers/azure_openai/provider.py +9 -4
  57. janito/providers/deepseek/provider.py +5 -4
  58. janito/providers/google/model_info.py +4 -2
  59. janito/providers/google/provider.py +11 -5
  60. janito/providers/groq/__init__.py +1 -0
  61. janito/providers/groq/model_info.py +46 -0
  62. janito/providers/groq/provider.py +76 -0
  63. janito/providers/moonshotai/__init__.py +1 -0
  64. janito/providers/moonshotai/model_info.py +15 -0
  65. janito/providers/moonshotai/provider.py +89 -0
  66. janito/providers/openai/provider.py +6 -7
  67. janito/tools/__init__.py +2 -0
  68. janito/tools/adapters/local/__init__.py +67 -66
  69. janito/tools/adapters/local/adapter.py +21 -4
  70. janito/tools/adapters/local/ask_user.py +1 -0
  71. janito/tools/adapters/local/copy_file.py +1 -0
  72. janito/tools/adapters/local/create_directory.py +1 -0
  73. janito/tools/adapters/local/create_file.py +1 -0
  74. janito/tools/adapters/local/delete_text_in_file.py +2 -1
  75. janito/tools/adapters/local/fetch_url.py +1 -0
  76. janito/tools/adapters/local/find_files.py +7 -6
  77. janito/tools/adapters/local/get_file_outline/core.py +1 -0
  78. janito/tools/adapters/local/get_file_outline/java_outline.py +22 -15
  79. janito/tools/adapters/local/get_file_outline/search_outline.py +1 -0
  80. janito/tools/adapters/local/move_file.py +4 -3
  81. janito/tools/adapters/local/open_html_in_browser.py +15 -5
  82. janito/tools/adapters/local/open_url.py +1 -0
  83. janito/tools/adapters/local/python_code_run.py +1 -0
  84. janito/tools/adapters/local/python_command_run.py +1 -0
  85. janito/tools/adapters/local/python_file_run.py +1 -0
  86. janito/tools/adapters/local/read_files.py +55 -40
  87. janito/tools/adapters/local/remove_directory.py +1 -0
  88. janito/tools/adapters/local/remove_file.py +1 -0
  89. janito/tools/adapters/local/replace_text_in_file.py +4 -3
  90. janito/tools/adapters/local/run_bash_command.py +1 -0
  91. janito/tools/adapters/local/run_powershell_command.py +1 -0
  92. janito/tools/adapters/local/search_text/core.py +18 -17
  93. janito/tools/adapters/local/search_text/match_lines.py +5 -5
  94. janito/tools/adapters/local/search_text/pattern_utils.py +1 -1
  95. janito/tools/adapters/local/search_text/traverse_directory.py +7 -7
  96. janito/tools/adapters/local/validate_file_syntax/core.py +1 -1
  97. janito/tools/adapters/local/validate_file_syntax/html_validator.py +8 -1
  98. janito/tools/disabled_tools.py +68 -0
  99. janito/tools/path_security.py +18 -11
  100. janito/tools/permissions.py +6 -0
  101. janito/tools/permissions_parse.py +4 -3
  102. janito/tools/tool_base.py +11 -5
  103. janito/tools/tool_use_tracker.py +1 -4
  104. janito/tools/tool_utils.py +1 -1
  105. janito/tools/tools_adapter.py +57 -25
  106. {janito-2.6.1.dist-info → janito-2.8.0.dist-info}/METADATA +411 -417
  107. janito-2.8.0.dist-info/RECORD +202 -0
  108. janito/cli/chat_mode/shell/commands/livelogs.py +0 -49
  109. janito/drivers/mistralai/driver.py +0 -41
  110. janito/providers/mistralai/model_info.py +0 -37
  111. janito/providers/mistralai/provider.py +0 -72
  112. janito/providers/provider_static_info.py +0 -18
  113. janito-2.6.1.dist-info/RECORD +0 -199
  114. /janito/agent/templates/profiles/{system_prompt_template_assistant.txt.j2 → system_prompt_template_model_conversation_without_tools_or_context.txt.j2} +0 -0
  115. {janito-2.6.1.dist-info → janito-2.8.0.dist-info}/WHEEL +0 -0
  116. {janito-2.6.1.dist-info → janito-2.8.0.dist-info}/entry_points.txt +0 -0
  117. {janito-2.6.1.dist-info → janito-2.8.0.dist-info}/licenses/LICENSE +0 -0
  118. {janito-2.6.1.dist-info → janito-2.8.0.dist-info}/top_level.txt +0 -0
@@ -1,436 +1,452 @@
1
- import uuid
2
- import traceback
3
- import re
4
- import json
5
- import math
6
- import time
7
- import os
8
- import logging
9
- from rich import pretty
10
- from janito.llm.driver import LLMDriver
11
- from janito.llm.driver_input import DriverInput
12
- from janito.driver_events import RequestFinished, RequestStatus, RateLimitRetry
13
- from janito.llm.message_parts import TextMessagePart, FunctionCallMessagePart
14
-
15
- # Safe import of openai SDK
16
- try:
17
- import openai
18
-
19
- DRIVER_AVAILABLE = True
20
- DRIVER_UNAVAILABLE_REASON = None
21
- except ImportError:
22
- DRIVER_AVAILABLE = False
23
- DRIVER_UNAVAILABLE_REASON = "Missing dependency: openai (pip install openai)"
24
-
25
-
26
- class OpenAIModelDriver(LLMDriver):
27
- def _get_message_from_result(self, result):
28
- """Extract the message object from the provider result (OpenAI-specific)."""
29
- if hasattr(result, "choices") and result.choices:
30
- return result.choices[0].message
31
- return None
32
-
33
- """
34
- OpenAI LLM driver (threaded, queue-based, stateless). Uses input/output queues accessible via instance attributes.
35
- """
36
- available = DRIVER_AVAILABLE
37
- unavailable_reason = DRIVER_UNAVAILABLE_REASON
38
-
39
- def __init__(self, tools_adapter=None, provider_name=None):
40
- super().__init__(tools_adapter=tools_adapter, provider_name=provider_name)
41
-
42
- def _prepare_api_kwargs(self, config, conversation):
43
- """
44
- Prepares API kwargs for OpenAI, including tool schemas if tools_adapter is present,
45
- and OpenAI-specific arguments (model, max_tokens, temperature, etc.).
46
- """
47
- api_kwargs = {}
48
- # Tool schemas (moved from base)
49
- if self.tools_adapter:
50
- try:
51
- from janito.providers.openai.schema_generator import (
52
- generate_tool_schemas,
53
- )
54
-
55
- tool_classes = self.tools_adapter.get_tool_classes()
56
- tool_schemas = generate_tool_schemas(tool_classes)
57
- api_kwargs["tools"] = tool_schemas
58
- except Exception as e:
59
- api_kwargs["tools"] = []
60
- if hasattr(config, "verbose_api") and config.verbose_api:
61
- print(f"[OpenAIModelDriver] Tool schema generation failed: {e}")
62
- # OpenAI-specific parameters
63
- if config.model:
64
- api_kwargs["model"] = config.model
65
- # Prefer max_completion_tokens if present, else fallback to max_tokens (for backward compatibility)
66
- if (
67
- hasattr(config, "max_completion_tokens")
68
- and config.max_completion_tokens is not None
69
- ):
70
- api_kwargs["max_completion_tokens"] = int(config.max_completion_tokens)
71
- elif hasattr(config, "max_tokens") and config.max_tokens is not None:
72
- # For models that do not support 'max_tokens', map to 'max_completion_tokens'
73
- api_kwargs["max_completion_tokens"] = int(config.max_tokens)
74
- for p in (
75
- "temperature",
76
- "top_p",
77
- "presence_penalty",
78
- "frequency_penalty",
79
- "stop",
80
- "reasoning_effort",
81
- ):
82
- v = getattr(config, p, None)
83
- if v is not None:
84
- api_kwargs[p] = v
85
- api_kwargs["messages"] = conversation
86
- api_kwargs["stream"] = False
87
- return api_kwargs
88
-
89
- def _call_api(self, driver_input: DriverInput):
90
- """Call the OpenAI-compatible chat completion endpoint with retry and error handling."""
91
- cancel_event = getattr(driver_input, "cancel_event", None)
92
- config = driver_input.config
93
- conversation = self.convert_history_to_api_messages(driver_input.conversation_history)
94
- request_id = getattr(config, "request_id", None)
95
- self._print_api_call_start(config)
96
- client = self._instantiate_openai_client(config)
97
- api_kwargs = self._prepare_api_kwargs(config, conversation)
98
- max_retries = getattr(config, "max_retries", 3)
99
- attempt = 1
100
- while True:
101
- try:
102
- self._print_api_attempt(config, attempt, max_retries, api_kwargs)
103
- if self._check_cancel(cancel_event, request_id, before_call=True):
104
- return None
105
- result = client.chat.completions.create(**api_kwargs)
106
- if self._check_cancel(cancel_event, request_id, before_call=False):
107
- return None
108
- self._handle_api_success(config, result, request_id)
109
- return result
110
- except Exception as e:
111
- if self._handle_api_exception(e, config, api_kwargs, attempt, max_retries, request_id):
112
- attempt += 1
113
- continue
114
- raise
115
-
116
- def _print_api_call_start(self, config):
117
- if getattr(config, "verbose_api", False):
118
- tool_adapter_name = type(self.tools_adapter).__name__ if self.tools_adapter else None
119
- tool_names = []
120
- if self.tools_adapter and hasattr(self.tools_adapter, "list_tools"):
121
- try:
122
- tool_names = self.tools_adapter.list_tools()
123
- except Exception:
124
- tool_names = ["<error retrieving tools>"]
125
- print(
126
- f"[verbose-api] OpenAI API call about to be sent. Model: {config.model}, max_tokens: {config.max_tokens}, tools_adapter: {tool_adapter_name}, tool_names: {tool_names}",
127
- flush=True,
128
- )
129
-
130
- def _print_api_attempt(self, config, attempt, max_retries, api_kwargs):
131
- if getattr(config, "verbose_api", False):
132
- print(
133
- f"[OpenAI] API CALL (attempt {attempt}/{max_retries}): chat.completions.create(**{api_kwargs})",
134
- flush=True,
135
- )
136
-
137
- def _handle_api_success(self, config, result, request_id):
138
- self._print_verbose_result(config, result)
139
- usage_dict = self._extract_usage(result)
140
- if getattr(config, "verbose_api", False):
141
- print(
142
- f"[OpenAI][DEBUG] Attaching usage info to RequestFinished: {usage_dict}",
143
- flush=True,
144
- )
145
- self.output_queue.put(
146
- RequestFinished(
147
- driver_name=self.__class__.__name__,
148
- request_id=request_id,
149
- response=result,
150
- status=RequestStatus.SUCCESS,
151
- usage=usage_dict,
152
- )
153
- )
154
- if getattr(config, "verbose_api", False):
155
- pretty.install()
156
- print("[OpenAI] API RESPONSE:", flush=True)
157
- pretty.pprint(result)
158
-
159
- def _handle_api_exception(self, e, config, api_kwargs, attempt, max_retries, request_id):
160
- status_code = getattr(e, "status_code", None)
161
- err_str = str(e)
162
- lower_err = err_str.lower()
163
- is_insufficient_quota = (
164
- "insufficient_quota" in lower_err or "exceeded your current quota" in lower_err
165
- )
166
- is_rate_limit = (
167
- (status_code == 429 or "error code: 429" in lower_err or "resource_exhausted" in lower_err)
168
- and not is_insufficient_quota
169
- )
170
- if not is_rate_limit or attempt > max_retries:
171
- self._handle_fatal_exception(e, config, api_kwargs)
172
- retry_delay = self._extract_retry_delay_seconds(e)
173
- if retry_delay is None:
174
- retry_delay = min(2 ** (attempt - 1), 30)
175
- self.output_queue.put(
176
- RateLimitRetry(
177
- driver_name=self.__class__.__name__,
178
- request_id=request_id,
179
- attempt=attempt,
180
- retry_delay=retry_delay,
181
- error=err_str,
182
- details={},
183
- )
184
- )
185
- if getattr(config, "verbose_api", False):
186
- print(
187
- f"[OpenAI][RateLimit] Attempt {attempt}/{max_retries} failed with rate-limit. Waiting {retry_delay}s before retry.",
188
- flush=True,
189
- )
190
- start_wait = time.time()
191
- while time.time() - start_wait < retry_delay:
192
- if self._check_cancel(getattr(config, "cancel_event", None), request_id, before_call=False):
193
- return False
194
- time.sleep(0.1)
195
- return True
196
-
197
- def _extract_retry_delay_seconds(self, exception) -> float | None:
198
- """Extract the retry delay in seconds from the provider error response.
199
-
200
- Handles both the Google Gemini style ``RetryInfo`` protobuf (where it's a
201
- ``retryDelay: '41s'`` string in JSON) and any number found after the word
202
- ``retryDelay``. Returns ``None`` if no delay could be parsed.
203
- """
204
- try:
205
- # Some SDKs expose the raw response JSON on e.args[0]
206
- if hasattr(exception, "response") and hasattr(exception.response, "text"):
207
- payload = exception.response.text
208
- else:
209
- payload = str(exception)
210
- # Look for 'retryDelay': '41s' or similar
211
- m = re.search(r"retryDelay['\"]?\s*[:=]\s*['\"]?(\d+(?:\.\d+)?)(s)?", payload)
212
- if m:
213
- return float(m.group(1))
214
- # Fallback: generic number of seconds in the message
215
- m2 = re.search(r"(\d+(?:\.\d+)?)\s*s(?:econds)?", payload)
216
- if m2:
217
- return float(m2.group(1))
218
- except Exception:
219
- pass
220
- return None
221
-
222
- def _handle_fatal_exception(self, e, config, api_kwargs):
223
- """Common path for unrecoverable exceptions.
224
-
225
- Prints diagnostics (respecting ``verbose_api``) then re-raises the
226
- exception so standard error handling in ``LLMDriver`` continues.
227
- """
228
- is_verbose = getattr(config, "verbose_api", False)
229
- if is_verbose:
230
- print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
231
- print(f"[ERROR] config: {config}", flush=True)
232
- print(
233
- f"[ERROR] api_kwargs: {api_kwargs if 'api_kwargs' in locals() else 'N/A'}",
234
- flush=True,
235
- )
236
- print("[ERROR] Full stack trace:", flush=True)
237
- print(traceback.format_exc(), flush=True)
238
- raise
239
-
240
- def _instantiate_openai_client(self, config):
241
- try:
242
- api_key_display = str(config.api_key)
243
- if api_key_display and len(api_key_display) > 8:
244
- api_key_display = api_key_display[:4] + "..." + api_key_display[-4:]
245
- client_kwargs = {"api_key": config.api_key}
246
- if getattr(config, "base_url", None):
247
- client_kwargs["base_url"] = config.base_url
248
-
249
- # HTTP debug wrapper
250
- if os.environ.get("OPENAI_DEBUG_HTTP", "0") == "1":
251
- from http.client import HTTPConnection
252
- HTTPConnection.debuglevel = 1
253
- logging.basicConfig()
254
- logging.getLogger().setLevel(logging.DEBUG)
255
- requests_log = logging.getLogger("http.client")
256
- requests_log.setLevel(logging.DEBUG)
257
- requests_log.propagate = True
258
- print("[OpenAIModelDriver] HTTP debug enabled via OPENAI_DEBUG_HTTP=1", flush=True)
259
-
260
- client = openai.OpenAI(**client_kwargs)
261
- return client
262
- except Exception as e:
263
- print(
264
- f"[ERROR] Exception during OpenAI client instantiation: {e}", flush=True
265
- )
266
- print(traceback.format_exc(), flush=True)
267
- raise
268
-
269
- def _check_cancel(self, cancel_event, request_id, before_call=True):
270
- if cancel_event is not None and cancel_event.is_set():
271
- status = RequestStatus.CANCELLED
272
- reason = (
273
- "Cancelled before API call"
274
- if before_call
275
- else "Cancelled during API call"
276
- )
277
- self.output_queue.put(
278
- RequestFinished(
279
- driver_name=self.__class__.__name__,
280
- request_id=request_id,
281
- status=status,
282
- reason=reason,
283
- )
284
- )
285
- return True
286
- return False
287
-
288
- def _print_verbose_result(self, config, result):
289
- if config.verbose_api:
290
- print("[OpenAI] API RAW RESULT:", flush=True)
291
- pretty.pprint(result)
292
- if hasattr(result, "__dict__"):
293
- print("[OpenAI] API RESULT __dict__:", flush=True)
294
- pretty.pprint(result.__dict__)
295
- try:
296
- print("[OpenAI] API RESULT as dict:", dict(result), flush=True)
297
- except Exception:
298
- pass
299
- print(
300
- f"[OpenAI] API RESULT .usage: {getattr(result, 'usage', None)}",
301
- flush=True,
302
- )
303
- try:
304
- print(f"[OpenAI] API RESULT ['usage']: {result['usage']}", flush=True)
305
- except Exception:
306
- pass
307
- if not hasattr(result, "usage") or getattr(result, "usage", None) is None:
308
- print(
309
- "[OpenAI][WARNING] No usage info found in API response.", flush=True
310
- )
311
-
312
- def _extract_usage(self, result):
313
- usage = getattr(result, "usage", None)
314
- if usage is not None:
315
- usage_dict = self._usage_to_dict(usage)
316
- if usage_dict is None:
317
- print(
318
- "[OpenAI][WARNING] Could not convert usage to dict, using string fallback.",
319
- flush=True,
320
- )
321
- usage_dict = str(usage)
322
- else:
323
- usage_dict = self._extract_usage_from_result_dict(result)
324
- return usage_dict
325
-
326
- def _usage_to_dict(self, usage):
327
- if hasattr(usage, "model_dump") and callable(getattr(usage, "model_dump")):
328
- try:
329
- return usage.model_dump()
330
- except Exception:
331
- pass
332
- if hasattr(usage, "dict") and callable(getattr(usage, "dict")):
333
- try:
334
- return usage.dict()
335
- except Exception:
336
- pass
337
- try:
338
- return dict(usage)
339
- except Exception:
340
- try:
341
- return vars(usage)
342
- except Exception:
343
- pass
344
- return None
345
-
346
- def _extract_usage_from_result_dict(self, result):
347
- try:
348
- return result["usage"]
349
- except Exception:
350
- return None
351
-
352
- def convert_history_to_api_messages(self, conversation_history):
353
- """
354
- Convert LLMConversationHistory to the list of dicts required by OpenAI's API.
355
- Handles 'tool_results' and 'tool_calls' roles for compliance.
356
- """
357
- api_messages = []
358
- for msg in conversation_history.get_history():
359
- self._append_api_message(api_messages, msg)
360
- self._replace_none_content(api_messages)
361
- return api_messages
362
-
363
- def _append_api_message(self, api_messages, msg):
364
- role = msg.get("role")
365
- content = msg.get("content")
366
- if role == "tool_results":
367
- self._handle_tool_results(api_messages, content)
368
- elif role == "tool_calls":
369
- self._handle_tool_calls(api_messages, content)
370
- else:
371
- self._handle_other_roles(api_messages, msg, role, content)
372
-
373
- def _handle_tool_results(self, api_messages, content):
374
- try:
375
- results = json.loads(content) if isinstance(content, str) else content
376
- except Exception:
377
- results = [content]
378
- for result in results:
379
- if isinstance(result, dict):
380
- api_messages.append({
381
- "role": "tool",
382
- "content": result.get("content", ""),
383
- "name": result.get("name", ""),
384
- "tool_call_id": result.get("tool_call_id", ""),
385
- })
386
- else:
387
- api_messages.append({
388
- "role": "tool",
389
- "content": str(result),
390
- "name": "",
391
- "tool_call_id": "",
392
- })
393
-
394
- def _handle_tool_calls(self, api_messages, content):
395
- try:
396
- tool_calls = json.loads(content) if isinstance(content, str) else content
397
- except Exception:
398
- tool_calls = []
399
- api_messages.append({"role": "assistant", "content": "", "tool_calls": tool_calls})
400
-
401
- def _handle_other_roles(self, api_messages, msg, role, content):
402
- if role == "function":
403
- name = ""
404
- if isinstance(msg, dict):
405
- metadata = msg.get("metadata", {})
406
- name = metadata.get("name", "") if isinstance(metadata, dict) else ""
407
- api_messages.append({"role": "tool", "content": content, "name": name})
408
- else:
409
- api_messages.append(msg)
410
-
411
- def _replace_none_content(self, api_messages):
412
- for m in api_messages:
413
- if m.get("content", None) is None:
414
- m["content"] = ""
415
-
416
- def _convert_completion_message_to_parts(self, message):
417
- """
418
- Convert an OpenAI completion message object to a list of MessagePart objects.
419
- Handles text, tool calls, and can be extended for other types.
420
- """
421
- parts = []
422
- # Text content
423
- content = getattr(message, "content", None)
424
- if content:
425
- parts.append(TextMessagePart(content=content))
426
- # Tool calls
427
- tool_calls = getattr(message, "tool_calls", None) or []
428
- for tool_call in tool_calls:
429
- parts.append(
430
- FunctionCallMessagePart(
431
- tool_call_id=getattr(tool_call, "id", ""),
432
- function=getattr(tool_call, "function", None),
433
- )
434
- )
435
- # Extend here for other message part types if needed
436
- return parts
1
+ import uuid
2
+ import traceback
3
+ import re
4
+ import json
5
+ import math
6
+ import time
7
+ import os
8
+ import logging
9
+ from rich import pretty
10
+ from janito.llm.driver import LLMDriver
11
+ from janito.llm.driver_input import DriverInput
12
+ from janito.driver_events import RequestFinished, RequestStatus, RateLimitRetry
13
+ from janito.llm.message_parts import TextMessagePart, FunctionCallMessagePart
14
+
15
+ import openai
16
+
17
+
18
+ class OpenAIModelDriver(LLMDriver):
19
+ def _get_message_from_result(self, result):
20
+ """Extract the message object from the provider result (OpenAI-specific)."""
21
+ if hasattr(result, "choices") and result.choices:
22
+ return result.choices[0].message
23
+ return None
24
+
25
+ """
26
+ OpenAI LLM driver (threaded, queue-based, stateless). Uses input/output queues accessible via instance attributes.
27
+ """
28
+
29
+ def __init__(self, tools_adapter=None, provider_name=None):
30
+ super().__init__(tools_adapter=tools_adapter, provider_name=provider_name)
31
+
32
+ def _prepare_api_kwargs(self, config, conversation):
33
+ """
34
+ Prepares API kwargs for OpenAI, including tool schemas if tools_adapter is present,
35
+ and OpenAI-specific arguments (model, max_tokens, temperature, etc.).
36
+ """
37
+ api_kwargs = {}
38
+ # Tool schemas (moved from base)
39
+ if self.tools_adapter:
40
+ try:
41
+ from janito.providers.openai.schema_generator import (
42
+ generate_tool_schemas,
43
+ )
44
+
45
+ tool_classes = self.tools_adapter.get_tool_classes()
46
+ tool_schemas = generate_tool_schemas(tool_classes)
47
+ api_kwargs["tools"] = tool_schemas
48
+ except Exception as e:
49
+ api_kwargs["tools"] = []
50
+ if hasattr(config, "verbose_api") and config.verbose_api:
51
+ print(f"[OpenAIModelDriver] Tool schema generation failed: {e}")
52
+ # OpenAI-specific parameters
53
+ if config.model:
54
+ api_kwargs["model"] = config.model
55
+ # Prefer max_completion_tokens if present, else fallback to max_tokens (for backward compatibility)
56
+ if (
57
+ hasattr(config, "max_completion_tokens")
58
+ and config.max_completion_tokens is not None
59
+ ):
60
+ api_kwargs["max_completion_tokens"] = int(config.max_completion_tokens)
61
+ elif hasattr(config, "max_tokens") and config.max_tokens is not None:
62
+ # For models that do not support 'max_tokens', map to 'max_completion_tokens'
63
+ api_kwargs["max_completion_tokens"] = int(config.max_tokens)
64
+ for p in (
65
+ "temperature",
66
+ "top_p",
67
+ "presence_penalty",
68
+ "frequency_penalty",
69
+ "stop",
70
+ "reasoning_effort",
71
+ ):
72
+ v = getattr(config, p, None)
73
+ if v is not None:
74
+ api_kwargs[p] = v
75
+ api_kwargs["messages"] = conversation
76
+ api_kwargs["stream"] = False
77
+ if self.tools_adapter and self.tools_adapter.get_tool_classes():
78
+ api_kwargs["parallel_tool_calls"] = True
79
+ return api_kwargs
80
+
81
+ def _call_api(self, driver_input: DriverInput):
82
+ """Call the OpenAI-compatible chat completion endpoint with retry and error handling."""
83
+ cancel_event = getattr(driver_input, "cancel_event", None)
84
+ config = driver_input.config
85
+ conversation = self.convert_history_to_api_messages(
86
+ driver_input.conversation_history
87
+ )
88
+ request_id = getattr(config, "request_id", None)
89
+ self._print_api_call_start(config)
90
+ client = self._instantiate_openai_client(config)
91
+ api_kwargs = self._prepare_api_kwargs(config, conversation)
92
+ max_retries = getattr(config, "max_retries", 3)
93
+ attempt = 1
94
+ while True:
95
+ try:
96
+ self._print_api_attempt(config, attempt, max_retries, api_kwargs)
97
+ if self._check_cancel(cancel_event, request_id, before_call=True):
98
+ return None
99
+ result = client.chat.completions.create(**api_kwargs)
100
+ if self._check_cancel(cancel_event, request_id, before_call=False):
101
+ return None
102
+ self._handle_api_success(config, result, request_id)
103
+ return result
104
+ except Exception as e:
105
+ if self._handle_api_exception(
106
+ e, config, api_kwargs, attempt, max_retries, request_id
107
+ ):
108
+ attempt += 1
109
+ continue
110
+ raise
111
+
112
+ def _print_api_call_start(self, config):
113
+ if getattr(config, "verbose_api", False):
114
+ tool_adapter_name = (
115
+ type(self.tools_adapter).__name__ if self.tools_adapter else None
116
+ )
117
+ tool_names = []
118
+ if self.tools_adapter and hasattr(self.tools_adapter, "list_tools"):
119
+ try:
120
+ tool_names = self.tools_adapter.list_tools()
121
+ except Exception:
122
+ tool_names = ["<error retrieving tools>"]
123
+ print(
124
+ f"[verbose-api] OpenAI API call about to be sent. Model: {config.model}, max_tokens: {config.max_tokens}, tools_adapter: {tool_adapter_name}, tool_names: {tool_names}",
125
+ flush=True,
126
+ )
127
+
128
+ def _print_api_attempt(self, config, attempt, max_retries, api_kwargs):
129
+ if getattr(config, "verbose_api", False):
130
+ print(
131
+ f"[OpenAI] API CALL (attempt {attempt}/{max_retries}): chat.completions.create(**{api_kwargs})",
132
+ flush=True,
133
+ )
134
+
135
+ def _handle_api_success(self, config, result, request_id):
136
+ self._print_verbose_result(config, result)
137
+ usage_dict = self._extract_usage(result)
138
+ if getattr(config, "verbose_api", False):
139
+ print(
140
+ f"[OpenAI][DEBUG] Attaching usage info to RequestFinished: {usage_dict}",
141
+ flush=True,
142
+ )
143
+ self.output_queue.put(
144
+ RequestFinished(
145
+ driver_name=self.__class__.__name__,
146
+ request_id=request_id,
147
+ response=result,
148
+ status=RequestStatus.SUCCESS,
149
+ usage=usage_dict,
150
+ )
151
+ )
152
+ if getattr(config, "verbose_api", False):
153
+ pretty.install()
154
+ print("[OpenAI] API RESPONSE:", flush=True)
155
+ pretty.pprint(result)
156
+
157
+ def _handle_api_exception(
158
+ self, e, config, api_kwargs, attempt, max_retries, request_id
159
+ ):
160
+ status_code = getattr(e, "status_code", None)
161
+ err_str = str(e)
162
+ lower_err = err_str.lower()
163
+ is_insufficient_quota = (
164
+ "insufficient_quota" in lower_err
165
+ or "exceeded your current quota" in lower_err
166
+ )
167
+ is_rate_limit = (
168
+ status_code == 429
169
+ or "error code: 429" in lower_err
170
+ or "resource_exhausted" in lower_err
171
+ ) and not is_insufficient_quota
172
+ if not is_rate_limit or attempt > max_retries:
173
+ self._handle_fatal_exception(e, config, api_kwargs)
174
+ retry_delay = self._extract_retry_delay_seconds(e)
175
+ if retry_delay is None:
176
+ retry_delay = min(2 ** (attempt - 1), 30)
177
+ self.output_queue.put(
178
+ RateLimitRetry(
179
+ driver_name=self.__class__.__name__,
180
+ request_id=request_id,
181
+ attempt=attempt,
182
+ retry_delay=retry_delay,
183
+ error=err_str,
184
+ details={},
185
+ )
186
+ )
187
+ if getattr(config, "verbose_api", False):
188
+ print(
189
+ f"[OpenAI][RateLimit] Attempt {attempt}/{max_retries} failed with rate-limit. Waiting {retry_delay}s before retry.",
190
+ flush=True,
191
+ )
192
+ start_wait = time.time()
193
+ while time.time() - start_wait < retry_delay:
194
+ if self._check_cancel(
195
+ getattr(config, "cancel_event", None), request_id, before_call=False
196
+ ):
197
+ return False
198
+ time.sleep(0.1)
199
+ return True
200
+
201
+ def _extract_retry_delay_seconds(self, exception) -> float | None:
202
+ """Extract the retry delay in seconds from the provider error response.
203
+
204
+ Handles both the Google Gemini style ``RetryInfo`` protobuf (where it's a
205
+ ``retryDelay: '41s'`` string in JSON) and any number found after the word
206
+ ``retryDelay``. Returns ``None`` if no delay could be parsed.
207
+ """
208
+ try:
209
+ # Some SDKs expose the raw response JSON on e.args[0]
210
+ if hasattr(exception, "response") and hasattr(exception.response, "text"):
211
+ payload = exception.response.text
212
+ else:
213
+ payload = str(exception)
214
+ # Look for 'retryDelay': '41s' or similar
215
+ m = re.search(
216
+ r"retryDelay['\"]?\s*[:=]\s*['\"]?(\d+(?:\.\d+)?)(s)?", payload
217
+ )
218
+ if m:
219
+ return float(m.group(1))
220
+ # Fallback: generic number of seconds in the message
221
+ m2 = re.search(r"(\d+(?:\.\d+)?)\s*s(?:econds)?", payload)
222
+ if m2:
223
+ return float(m2.group(1))
224
+ except Exception:
225
+ pass
226
+ return None
227
+
228
+ def _handle_fatal_exception(self, e, config, api_kwargs):
229
+ """Common path for unrecoverable exceptions.
230
+
231
+ Prints diagnostics (respecting ``verbose_api``) then re-raises the
232
+ exception so standard error handling in ``LLMDriver`` continues.
233
+ """
234
+ is_verbose = getattr(config, "verbose_api", False)
235
+ if is_verbose:
236
+ print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
237
+ print(f"[ERROR] config: {config}", flush=True)
238
+ print(
239
+ f"[ERROR] api_kwargs: {api_kwargs if 'api_kwargs' in locals() else 'N/A'}",
240
+ flush=True,
241
+ )
242
+ print("[ERROR] Full stack trace:", flush=True)
243
+ print(traceback.format_exc(), flush=True)
244
+ raise
245
+
246
+ def _instantiate_openai_client(self, config):
247
+ try:
248
+ api_key_display = str(config.api_key)
249
+ if api_key_display and len(api_key_display) > 8:
250
+ api_key_display = api_key_display[:4] + "..." + api_key_display[-4:]
251
+ client_kwargs = {"api_key": config.api_key}
252
+ if getattr(config, "base_url", None):
253
+ client_kwargs["base_url"] = config.base_url
254
+
255
+ # HTTP debug wrapper
256
+ if os.environ.get("OPENAI_DEBUG_HTTP", "0") == "1":
257
+ from http.client import HTTPConnection
258
+
259
+ HTTPConnection.debuglevel = 1
260
+ logging.basicConfig()
261
+ logging.getLogger().setLevel(logging.DEBUG)
262
+ requests_log = logging.getLogger("http.client")
263
+ requests_log.setLevel(logging.DEBUG)
264
+ requests_log.propagate = True
265
+ print(
266
+ "[OpenAIModelDriver] HTTP debug enabled via OPENAI_DEBUG_HTTP=1",
267
+ flush=True,
268
+ )
269
+
270
+ client = openai.OpenAI(**client_kwargs)
271
+ return client
272
+ except Exception as e:
273
+ print(
274
+ f"[ERROR] Exception during OpenAI client instantiation: {e}", flush=True
275
+ )
276
+ print(traceback.format_exc(), flush=True)
277
+ raise
278
+
279
+ def _check_cancel(self, cancel_event, request_id, before_call=True):
280
+ if cancel_event is not None and cancel_event.is_set():
281
+ status = RequestStatus.CANCELLED
282
+ reason = (
283
+ "Cancelled before API call"
284
+ if before_call
285
+ else "Cancelled during API call"
286
+ )
287
+ self.output_queue.put(
288
+ RequestFinished(
289
+ driver_name=self.__class__.__name__,
290
+ request_id=request_id,
291
+ status=status,
292
+ reason=reason,
293
+ )
294
+ )
295
+ return True
296
+ return False
297
+
298
+ def _print_verbose_result(self, config, result):
299
+ if config.verbose_api:
300
+ print("[OpenAI] API RAW RESULT:", flush=True)
301
+ pretty.pprint(result)
302
+ if hasattr(result, "__dict__"):
303
+ print("[OpenAI] API RESULT __dict__:", flush=True)
304
+ pretty.pprint(result.__dict__)
305
+ try:
306
+ print("[OpenAI] API RESULT as dict:", dict(result), flush=True)
307
+ except Exception:
308
+ pass
309
+ print(
310
+ f"[OpenAI] API RESULT .usage: {getattr(result, 'usage', None)}",
311
+ flush=True,
312
+ )
313
+ try:
314
+ print(f"[OpenAI] API RESULT ['usage']: {result['usage']}", flush=True)
315
+ except Exception:
316
+ pass
317
+ if not hasattr(result, "usage") or getattr(result, "usage", None) is None:
318
+ print(
319
+ "[OpenAI][WARNING] No usage info found in API response.", flush=True
320
+ )
321
+
322
+ def _extract_usage(self, result):
323
+ usage = getattr(result, "usage", None)
324
+ if usage is not None:
325
+ usage_dict = self._usage_to_dict(usage)
326
+ if usage_dict is None:
327
+ print(
328
+ "[OpenAI][WARNING] Could not convert usage to dict, using string fallback.",
329
+ flush=True,
330
+ )
331
+ usage_dict = str(usage)
332
+ else:
333
+ usage_dict = self._extract_usage_from_result_dict(result)
334
+ return usage_dict
335
+
336
+ def _usage_to_dict(self, usage):
337
+ if hasattr(usage, "model_dump") and callable(getattr(usage, "model_dump")):
338
+ try:
339
+ return usage.model_dump()
340
+ except Exception:
341
+ pass
342
+ if hasattr(usage, "dict") and callable(getattr(usage, "dict")):
343
+ try:
344
+ return usage.dict()
345
+ except Exception:
346
+ pass
347
+ try:
348
+ return dict(usage)
349
+ except Exception:
350
+ try:
351
+ return vars(usage)
352
+ except Exception:
353
+ pass
354
+ return None
355
+
356
+ def _extract_usage_from_result_dict(self, result):
357
+ try:
358
+ return result["usage"]
359
+ except Exception:
360
+ return None
361
+
362
+ def convert_history_to_api_messages(self, conversation_history):
363
+ """
364
+ Convert LLMConversationHistory to the list of dicts required by OpenAI's API.
365
+ Handles 'tool_results' and 'tool_calls' roles for compliance.
366
+ """
367
+ api_messages = []
368
+ for msg in conversation_history.get_history():
369
+ self._append_api_message(api_messages, msg)
370
+ self._replace_none_content(api_messages)
371
+ return api_messages
372
+
373
+ def _append_api_message(self, api_messages, msg):
374
+ role = msg.get("role")
375
+ content = msg.get("content")
376
+ if role == "tool_results":
377
+ self._handle_tool_results(api_messages, content)
378
+ elif role == "tool_calls":
379
+ self._handle_tool_calls(api_messages, content)
380
+ else:
381
+ self._handle_other_roles(api_messages, msg, role, content)
382
+
383
+ def _handle_tool_results(self, api_messages, content):
384
+ try:
385
+ results = json.loads(content) if isinstance(content, str) else content
386
+ except Exception:
387
+ results = [content]
388
+ for result in results:
389
+ if isinstance(result, dict):
390
+ api_messages.append(
391
+ {
392
+ "role": "tool",
393
+ "content": result.get("content", ""),
394
+ "name": result.get("name", ""),
395
+ "tool_call_id": result.get("tool_call_id", ""),
396
+ }
397
+ )
398
+ else:
399
+ api_messages.append(
400
+ {
401
+ "role": "tool",
402
+ "content": str(result),
403
+ "name": "",
404
+ "tool_call_id": "",
405
+ }
406
+ )
407
+
408
+ def _handle_tool_calls(self, api_messages, content):
409
+ try:
410
+ tool_calls = json.loads(content) if isinstance(content, str) else content
411
+ except Exception:
412
+ tool_calls = []
413
+ api_messages.append(
414
+ {"role": "assistant", "content": "", "tool_calls": tool_calls}
415
+ )
416
+
417
+ def _handle_other_roles(self, api_messages, msg, role, content):
418
+ if role == "function":
419
+ name = ""
420
+ if isinstance(msg, dict):
421
+ metadata = msg.get("metadata", {})
422
+ name = metadata.get("name", "") if isinstance(metadata, dict) else ""
423
+ api_messages.append({"role": "tool", "content": content, "name": name})
424
+ else:
425
+ api_messages.append(msg)
426
+
427
+ def _replace_none_content(self, api_messages):
428
+ for m in api_messages:
429
+ if m.get("content", None) is None:
430
+ m["content"] = ""
431
+
432
+ def _convert_completion_message_to_parts(self, message):
433
+ """
434
+ Convert an OpenAI completion message object to a list of MessagePart objects.
435
+ Handles text, tool calls, and can be extended for other types.
436
+ """
437
+ parts = []
438
+ # Text content
439
+ content = getattr(message, "content", None)
440
+ if content:
441
+ parts.append(TextMessagePart(content=content))
442
+ # Tool calls
443
+ tool_calls = getattr(message, "tool_calls", None) or []
444
+ for tool_call in tool_calls:
445
+ parts.append(
446
+ FunctionCallMessagePart(
447
+ tool_call_id=getattr(tool_call, "id", ""),
448
+ function=getattr(tool_call, "function", None),
449
+ )
450
+ )
451
+ # Extend here for other message part types if needed
452
+ return parts