janito 2.5.1__py3-none-any.whl → 2.6.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 (61) hide show
  1. janito/agent/setup_agent.py +231 -223
  2. janito/agent/templates/profiles/system_prompt_template_software_developer.txt.j2 +39 -0
  3. janito/cli/chat_mode/bindings.py +1 -26
  4. janito/cli/chat_mode/session.py +282 -294
  5. janito/cli/chat_mode/session_profile_select.py +125 -55
  6. janito/cli/chat_mode/shell/commands/tools.py +51 -48
  7. janito/cli/chat_mode/toolbar.py +42 -68
  8. janito/cli/cli_commands/list_tools.py +41 -56
  9. janito/cli/cli_commands/show_system_prompt.py +70 -49
  10. janito/cli/core/runner.py +6 -1
  11. janito/cli/core/setters.py +43 -34
  12. janito/cli/main_cli.py +25 -1
  13. janito/cli/prompt_core.py +76 -69
  14. janito/cli/rich_terminal_reporter.py +22 -1
  15. janito/cli/single_shot_mode/handler.py +95 -94
  16. janito/drivers/driver_registry.py +27 -29
  17. janito/drivers/openai/driver.py +436 -494
  18. janito/llm/agent.py +54 -68
  19. janito/provider_registry.py +178 -178
  20. janito/providers/anthropic/model_info.py +41 -22
  21. janito/providers/anthropic/provider.py +80 -67
  22. janito/providers/provider_static_info.py +18 -17
  23. janito/tools/adapters/local/__init__.py +66 -65
  24. janito/tools/adapters/local/adapter.py +79 -18
  25. janito/tools/adapters/local/create_directory.py +9 -9
  26. janito/tools/adapters/local/create_file.py +12 -12
  27. janito/tools/adapters/local/delete_text_in_file.py +16 -16
  28. janito/tools/adapters/local/find_files.py +2 -2
  29. janito/tools/adapters/local/get_file_outline/core.py +5 -5
  30. janito/tools/adapters/local/get_file_outline/search_outline.py +4 -4
  31. janito/tools/adapters/local/open_html_in_browser.py +15 -15
  32. janito/tools/adapters/local/python_file_run.py +4 -4
  33. janito/tools/adapters/local/read_files.py +40 -0
  34. janito/tools/adapters/local/remove_directory.py +5 -5
  35. janito/tools/adapters/local/remove_file.py +4 -4
  36. janito/tools/adapters/local/replace_text_in_file.py +21 -21
  37. janito/tools/adapters/local/run_bash_command.py +1 -1
  38. janito/tools/adapters/local/search_text/pattern_utils.py +2 -2
  39. janito/tools/adapters/local/search_text/traverse_directory.py +10 -10
  40. janito/tools/adapters/local/validate_file_syntax/core.py +7 -7
  41. janito/tools/adapters/local/validate_file_syntax/css_validator.py +2 -2
  42. janito/tools/adapters/local/validate_file_syntax/html_validator.py +7 -7
  43. janito/tools/adapters/local/validate_file_syntax/js_validator.py +2 -2
  44. janito/tools/adapters/local/validate_file_syntax/json_validator.py +2 -2
  45. janito/tools/adapters/local/validate_file_syntax/markdown_validator.py +2 -2
  46. janito/tools/adapters/local/validate_file_syntax/ps1_validator.py +2 -2
  47. janito/tools/adapters/local/validate_file_syntax/python_validator.py +2 -2
  48. janito/tools/adapters/local/validate_file_syntax/xml_validator.py +2 -2
  49. janito/tools/adapters/local/validate_file_syntax/yaml_validator.py +2 -2
  50. janito/tools/adapters/local/view_file.py +12 -12
  51. janito/tools/path_security.py +204 -0
  52. janito/tools/tool_use_tracker.py +12 -12
  53. janito/tools/tools_adapter.py +66 -34
  54. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/METADATA +412 -412
  55. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/RECORD +59 -58
  56. janito/drivers/anthropic/driver.py +0 -113
  57. janito/tools/adapters/local/get_file_outline/python_outline_v2.py +0 -156
  58. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/WHEEL +0 -0
  59. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/entry_points.txt +0 -0
  60. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/licenses/LICENSE +0 -0
  61. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/top_level.txt +0 -0
@@ -1,494 +1,436 @@
1
- import uuid
2
- import traceback
3
- from rich import pretty
4
- import os
5
- from janito.llm.driver import LLMDriver
6
- from janito.llm.driver_input import DriverInput
7
- from janito.driver_events import RequestFinished, RequestStatus, RateLimitRetry
8
-
9
- # Safe import of openai SDK
10
- try:
11
- import openai
12
-
13
- DRIVER_AVAILABLE = True
14
- DRIVER_UNAVAILABLE_REASON = None
15
- except ImportError:
16
- DRIVER_AVAILABLE = False
17
- DRIVER_UNAVAILABLE_REASON = "Missing dependency: openai (pip install openai)"
18
-
19
-
20
- class OpenAIModelDriver(LLMDriver):
21
- def _get_message_from_result(self, result):
22
- """Extract the message object from the provider result (OpenAI-specific)."""
23
- if hasattr(result, "choices") and result.choices:
24
- return result.choices[0].message
25
- return None
26
-
27
- """
28
- OpenAI LLM driver (threaded, queue-based, stateless). Uses input/output queues accessible via instance attributes.
29
- """
30
- available = DRIVER_AVAILABLE
31
- unavailable_reason = DRIVER_UNAVAILABLE_REASON
32
-
33
- def __init__(self, tools_adapter=None, provider_name=None):
34
- super().__init__(tools_adapter=tools_adapter, provider_name=provider_name)
35
-
36
- def _prepare_api_kwargs(self, config, conversation):
37
- """
38
- Prepares API kwargs for OpenAI, including tool schemas if tools_adapter is present,
39
- and OpenAI-specific arguments (model, max_tokens, temperature, etc.).
40
- """
41
- api_kwargs = {}
42
- # Tool schemas (moved from base)
43
- if self.tools_adapter:
44
- try:
45
- from janito.providers.openai.schema_generator import (
46
- generate_tool_schemas,
47
- )
48
-
49
- tool_classes = self.tools_adapter.get_tool_classes()
50
- tool_schemas = generate_tool_schemas(tool_classes)
51
- api_kwargs["tools"] = tool_schemas
52
- except Exception as e:
53
- api_kwargs["tools"] = []
54
- if hasattr(config, "verbose_api") and config.verbose_api:
55
- print(f"[OpenAIModelDriver] Tool schema generation failed: {e}")
56
- # OpenAI-specific parameters
57
- if config.model:
58
- api_kwargs["model"] = config.model
59
- # Prefer max_completion_tokens if present, else fallback to max_tokens (for backward compatibility)
60
- if (
61
- hasattr(config, "max_completion_tokens")
62
- and config.max_completion_tokens is not None
63
- ):
64
- api_kwargs["max_completion_tokens"] = int(config.max_completion_tokens)
65
- elif hasattr(config, "max_tokens") and config.max_tokens is not None:
66
- # For models that do not support 'max_tokens', map to 'max_completion_tokens'
67
- api_kwargs["max_completion_tokens"] = int(config.max_tokens)
68
- for p in (
69
- "temperature",
70
- "top_p",
71
- "presence_penalty",
72
- "frequency_penalty",
73
- "stop",
74
- "reasoning_effort",
75
- ):
76
- v = getattr(config, p, None)
77
- if v is not None:
78
- api_kwargs[p] = v
79
- api_kwargs["messages"] = conversation
80
- api_kwargs["stream"] = False
81
- return api_kwargs
82
-
83
- def _call_api(self, driver_input: DriverInput):
84
- """Call the OpenAI-compatible chat completion endpoint.
85
-
86
- Implements automatic retry logic when the provider returns a *retriable*
87
- HTTP 429 or ``RESOURCE_EXHAUSTED`` error **that is not caused by quota
88
- exhaustion**. A ``RateLimitRetry`` driver event is emitted each time a
89
- retry is scheduled so that user-interfaces can inform the user about
90
- the wait.
91
-
92
- OpenAI uses the 429 status code both for temporary rate-limit errors *and*
93
- for permanent quota-exceeded errors (``insufficient_quota``). Retrying
94
- the latter is pointless, so we inspect the error payload for
95
- ``insufficient_quota`` or common quota-exceeded wording and treat those
96
- as fatal, bubbling them up as a regular RequestFinished/ERROR instead of
97
- emitting a RateLimitRetry.
98
- """
99
- cancel_event = getattr(driver_input, "cancel_event", None)
100
- config = driver_input.config
101
- conversation = self.convert_history_to_api_messages(
102
- driver_input.conversation_history
103
- )
104
- request_id = getattr(config, "request_id", None)
105
- if config.verbose_api:
106
- tool_adapter_name = type(self.tools_adapter).__name__ if self.tools_adapter else None
107
- tool_names = []
108
- if self.tools_adapter and hasattr(self.tools_adapter, "list_tools"):
109
- try:
110
- tool_names = self.tools_adapter.list_tools()
111
- except Exception:
112
- tool_names = ["<error retrieving tools>"]
113
- print(
114
- 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}",
115
- flush=True,
116
- )
117
- import time, re, json
118
-
119
- client = self._instantiate_openai_client(config)
120
- api_kwargs = self._prepare_api_kwargs(config, conversation)
121
- max_retries = getattr(config, "max_retries", 3)
122
- attempt = 1
123
- while True:
124
- try:
125
- if config.verbose_api:
126
- print(
127
- f"[OpenAI] API CALL (attempt {attempt}/{max_retries}): chat.completions.create(**{api_kwargs})",
128
- flush=True,
129
- )
130
- if self._check_cancel(cancel_event, request_id, before_call=True):
131
- return None
132
- result = client.chat.completions.create(**api_kwargs)
133
- if self._check_cancel(cancel_event, request_id, before_call=False):
134
- return None
135
- # Success path
136
- self._print_verbose_result(config, result)
137
- usage_dict = self._extract_usage(result)
138
- if config.verbose_api:
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 config.verbose_api:
153
- pretty.install()
154
- print("[OpenAI] API RESPONSE:", flush=True)
155
- pretty.pprint(result)
156
- return result
157
- except Exception as e:
158
- # Check for rate-limit errors (HTTP 429 or RESOURCE_EXHAUSTED)
159
- status_code = getattr(e, "status_code", None)
160
- err_str = str(e)
161
- # Determine if this is a retriable rate-limit error (HTTP 429) or a non-retriable
162
- # quota exhaustion error. OpenAI returns the same 429 status code for both, so we
163
- # additionally check for the ``insufficient_quota`` code or typical quota-related
164
- # strings in the error message. If the error is quota-related we treat it as fatal
165
- # so that the caller can surface a proper error message instead of silently
166
- # retrying forever.
167
- lower_err = err_str.lower()
168
- is_insufficient_quota = (
169
- "insufficient_quota" in lower_err
170
- or "exceeded your current quota" in lower_err
171
- )
172
- is_rate_limit = (
173
- (status_code == 429 or "error code: 429" in lower_err or "resource_exhausted" in lower_err)
174
- and not is_insufficient_quota
175
- )
176
- if not is_rate_limit or attempt > max_retries:
177
- # If it's not a rate-limit error or we've exhausted retries, handle as fatal
178
- self._handle_fatal_exception(e, config, api_kwargs)
179
- # Parse retry delay from error message (default 1s)
180
- retry_delay = self._extract_retry_delay_seconds(e)
181
- if retry_delay is None:
182
- # simple exponential backoff if not provided
183
- retry_delay = min(2 ** (attempt - 1), 30)
184
- # Emit RateLimitRetry event so UIs can show a spinner / message
185
- self.output_queue.put(
186
- RateLimitRetry(
187
- driver_name=self.__class__.__name__,
188
- request_id=request_id,
189
- attempt=attempt,
190
- retry_delay=retry_delay,
191
- error=err_str,
192
- details={},
193
- )
194
- )
195
- if config.verbose_api:
196
- print(
197
- f"[OpenAI][RateLimit] Attempt {attempt}/{max_retries} failed with rate-limit. Waiting {retry_delay}s before retry.",
198
- flush=True,
199
- )
200
- # Wait while still allowing cancellation
201
- start_wait = time.time()
202
- while time.time() - start_wait < retry_delay:
203
- if self._check_cancel(cancel_event, request_id, before_call=False):
204
- return None
205
- time.sleep(0.1)
206
- attempt += 1
207
- continue
208
- # console with large JSON payloads when the service returns HTTP 429.
209
- # We still surface the exception to the caller so that standard error
210
- # handling (e.g. retries in higher-level code) continues to work.
211
- status_code = getattr(e, "status_code", None)
212
- err_str = str(e)
213
- is_rate_limit = (
214
- status_code == 429
215
- or "Error code: 429" in err_str
216
- or "RESOURCE_EXHAUSTED" in err_str
217
- )
218
- is_verbose = getattr(config, "verbose_api", False)
219
-
220
- # Only print the full diagnostics if the user explicitly requested
221
- # verbose output or if the problem is not a rate-limit situation.
222
- if is_verbose or not is_rate_limit:
223
- print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
224
- print(f"[ERROR] config: {config}", flush=True)
225
- print(
226
- f"[ERROR] api_kwargs: {api_kwargs if 'api_kwargs' in locals() else 'N/A'}",
227
- flush=True,
228
- )
229
- import traceback
230
-
231
- print("[ERROR] Full stack trace:", flush=True)
232
- print(traceback.format_exc(), flush=True)
233
- # Re-raise so that the calling logic can convert this into a
234
- # RequestFinished event with status=ERROR.
235
- raise
236
-
237
- def _extract_retry_delay_seconds(self, exception) -> float | None:
238
- """Extract the retry delay in seconds from the provider error response.
239
-
240
- Handles both the Google Gemini style ``RetryInfo`` protobuf (where it's a
241
- ``retryDelay: '41s'`` string in JSON) and any number found after the word
242
- ``retryDelay``. Returns ``None`` if no delay could be parsed.
243
- """
244
- import re, json, math
245
-
246
- try:
247
- # Some SDKs expose the raw response JSON on e.args[0]
248
- if hasattr(exception, "response") and hasattr(exception.response, "text"):
249
- payload = exception.response.text
250
- else:
251
- payload = str(exception)
252
- # Look for 'retryDelay': '41s' or similar
253
- m = re.search(r"retryDelay['\"]?\s*[:=]\s*['\"]?(\d+(?:\.\d+)?)(s)?", payload)
254
- if m:
255
- return float(m.group(1))
256
- # Fallback: generic number of seconds in the message
257
- m2 = re.search(r"(\d+(?:\.\d+)?)\s*s(?:econds)?", payload)
258
- if m2:
259
- return float(m2.group(1))
260
- except Exception:
261
- pass
262
- return None
263
-
264
- def _handle_fatal_exception(self, e, config, api_kwargs):
265
- """Common path for unrecoverable exceptions.
266
-
267
- Prints diagnostics (respecting ``verbose_api``) then re-raises the
268
- exception so standard error handling in ``LLMDriver`` continues.
269
- """
270
- is_verbose = getattr(config, "verbose_api", False)
271
- if is_verbose:
272
- print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
273
- print(f"[ERROR] config: {config}", flush=True)
274
- print(
275
- f"[ERROR] api_kwargs: {api_kwargs if 'api_kwargs' in locals() else 'N/A'}",
276
- flush=True,
277
- )
278
- import traceback
279
- print("[ERROR] Full stack trace:", flush=True)
280
- print(traceback.format_exc(), flush=True)
281
- raise
282
-
283
- def _instantiate_openai_client(self, config):
284
- try:
285
- api_key_display = str(config.api_key)
286
- if api_key_display and len(api_key_display) > 8:
287
- api_key_display = api_key_display[:4] + "..." + api_key_display[-4:]
288
- client_kwargs = {"api_key": config.api_key}
289
- if getattr(config, "base_url", None):
290
- client_kwargs["base_url"] = config.base_url
291
-
292
- # HTTP debug wrapper
293
- if os.environ.get("OPENAI_DEBUG_HTTP", "0") == "1":
294
- from http.client import HTTPConnection
295
- HTTPConnection.debuglevel = 1
296
- import logging
297
- logging.basicConfig()
298
- logging.getLogger().setLevel(logging.DEBUG)
299
- requests_log = logging.getLogger("http.client")
300
- requests_log.setLevel(logging.DEBUG)
301
- requests_log.propagate = True
302
- print("[OpenAIModelDriver] HTTP debug enabled via OPENAI_DEBUG_HTTP=1", flush=True)
303
-
304
- client = openai.OpenAI(**client_kwargs)
305
- return client
306
- except Exception as e:
307
- print(
308
- f"[ERROR] Exception during OpenAI client instantiation: {e}", flush=True
309
- )
310
- import traceback
311
-
312
- print(traceback.format_exc(), flush=True)
313
- raise
314
-
315
- def _check_cancel(self, cancel_event, request_id, before_call=True):
316
- if cancel_event is not None and cancel_event.is_set():
317
- status = RequestStatus.CANCELLED
318
- reason = (
319
- "Cancelled before API call"
320
- if before_call
321
- else "Cancelled during API call"
322
- )
323
- self.output_queue.put(
324
- RequestFinished(
325
- driver_name=self.__class__.__name__,
326
- request_id=request_id,
327
- status=status,
328
- reason=reason,
329
- )
330
- )
331
- return True
332
- return False
333
-
334
- def _print_verbose_result(self, config, result):
335
- if config.verbose_api:
336
- print("[OpenAI] API RAW RESULT:", flush=True)
337
- pretty.pprint(result)
338
- if hasattr(result, "__dict__"):
339
- print("[OpenAI] API RESULT __dict__:", flush=True)
340
- pretty.pprint(result.__dict__)
341
- try:
342
- print("[OpenAI] API RESULT as dict:", dict(result), flush=True)
343
- except Exception:
344
- pass
345
- print(
346
- f"[OpenAI] API RESULT .usage: {getattr(result, 'usage', None)}",
347
- flush=True,
348
- )
349
- try:
350
- print(f"[OpenAI] API RESULT ['usage']: {result['usage']}", flush=True)
351
- except Exception:
352
- pass
353
- if not hasattr(result, "usage") or getattr(result, "usage", None) is None:
354
- print(
355
- "[OpenAI][WARNING] No usage info found in API response.", flush=True
356
- )
357
-
358
- def _extract_usage(self, result):
359
- usage = getattr(result, "usage", None)
360
- if usage is not None:
361
- usage_dict = self._usage_to_dict(usage)
362
- if usage_dict is None:
363
- print(
364
- "[OpenAI][WARNING] Could not convert usage to dict, using string fallback.",
365
- flush=True,
366
- )
367
- usage_dict = str(usage)
368
- else:
369
- usage_dict = self._extract_usage_from_result_dict(result)
370
- return usage_dict
371
-
372
- def _usage_to_dict(self, usage):
373
- if hasattr(usage, "model_dump") and callable(getattr(usage, "model_dump")):
374
- try:
375
- return usage.model_dump()
376
- except Exception:
377
- pass
378
- if hasattr(usage, "dict") and callable(getattr(usage, "dict")):
379
- try:
380
- return usage.dict()
381
- except Exception:
382
- pass
383
- try:
384
- return dict(usage)
385
- except Exception:
386
- try:
387
- return vars(usage)
388
- except Exception:
389
- pass
390
- return None
391
-
392
- def _extract_usage_from_result_dict(self, result):
393
- try:
394
- return result["usage"]
395
- except Exception:
396
- return None
397
-
398
- def convert_history_to_api_messages(self, conversation_history):
399
- """
400
- Convert LLMConversationHistory to the list of dicts required by OpenAI's API.
401
- Handles 'tool_results' and 'tool_calls' roles for compliance.
402
- """
403
- import json
404
-
405
- api_messages = []
406
- for msg in conversation_history.get_history():
407
- role = msg.get("role")
408
- content = msg.get("content")
409
- if role == "tool_results":
410
- # Expect content to be a list of tool result dicts or a stringified list
411
- try:
412
- results = (
413
- json.loads(content) if isinstance(content, str) else content
414
- )
415
- except Exception:
416
- results = [content]
417
- for result in results:
418
- # result should be a dict with keys: name, content, tool_call_id
419
- if isinstance(result, dict):
420
- api_messages.append(
421
- {
422
- "role": "tool",
423
- "content": result.get("content", ""),
424
- "name": result.get("name", ""),
425
- "tool_call_id": result.get("tool_call_id", ""),
426
- }
427
- )
428
- else:
429
- api_messages.append(
430
- {
431
- "role": "tool",
432
- "content": str(result),
433
- "name": "",
434
- "tool_call_id": "",
435
- }
436
- )
437
- elif role == "tool_calls":
438
- # Convert to assistant message with tool_calls field
439
- import json
440
-
441
- try:
442
- tool_calls = (
443
- json.loads(content) if isinstance(content, str) else content
444
- )
445
- except Exception:
446
- tool_calls = []
447
- api_messages.append(
448
- {"role": "assistant", "content": "", "tool_calls": tool_calls}
449
- )
450
- else:
451
- # Special handling for 'function' role: extract 'name' from metadata if present
452
- if role == "function":
453
- name = ""
454
- if isinstance(msg, dict):
455
- metadata = msg.get("metadata", {})
456
- name = (
457
- metadata.get("name", "")
458
- if isinstance(metadata, dict)
459
- else ""
460
- )
461
- api_messages.append(
462
- {"role": "tool", "content": content, "name": name}
463
- )
464
- else:
465
- api_messages.append(msg)
466
- # Post-processing: Google Gemini API (OpenAI-compatible) rejects null content. Replace None with empty string.
467
- for m in api_messages:
468
- if m.get("content", None) is None:
469
- m["content"] = ""
470
- return api_messages
471
-
472
- def _convert_completion_message_to_parts(self, message):
473
- """
474
- Convert an OpenAI completion message object to a list of MessagePart objects.
475
- Handles text, tool calls, and can be extended for other types.
476
- """
477
- from janito.llm.message_parts import TextMessagePart, FunctionCallMessagePart
478
-
479
- parts = []
480
- # Text content
481
- content = getattr(message, "content", None)
482
- if content:
483
- parts.append(TextMessagePart(content=content))
484
- # Tool calls
485
- tool_calls = getattr(message, "tool_calls", None) or []
486
- for tool_call in tool_calls:
487
- parts.append(
488
- FunctionCallMessagePart(
489
- tool_call_id=getattr(tool_call, "id", ""),
490
- function=getattr(tool_call, "function", None),
491
- )
492
- )
493
- # Extend here for other message part types if needed
494
- 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
+ # 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