janito 3.17.0__py3-none-any.whl → 3.17.1__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.
@@ -1,481 +1,485 @@
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
- try:
16
- import openai
17
- except ImportError:
18
- openai = None
19
-
20
-
21
- class OpenAIModelDriver(LLMDriver):
22
- # Check if required dependencies are available
23
- try:
24
- import openai
25
-
26
- available = True
27
- unavailable_reason = None
28
- except ImportError as e:
29
- available = False
30
- unavailable_reason = f"Missing dependency: {str(e)}"
31
-
32
- def _get_message_from_result(self, result):
33
- """Extract the message object from the provider result (OpenAI-specific)."""
34
- if hasattr(result, "choices") and result.choices:
35
- return result.choices[0].message
36
- return None
37
-
38
- """
39
- OpenAI LLM driver (threaded, queue-based, stateless). Uses input/output queues accessible via instance attributes.
40
- """
41
-
42
- def __init__(self, tools_adapter=None, provider_name=None):
43
- super().__init__(tools_adapter=tools_adapter, provider_name=provider_name)
44
-
45
- def _prepare_api_kwargs(self, config, conversation):
46
- """
47
- Prepares API kwargs for OpenAI, including tool schemas if tools_adapter is present,
48
- and OpenAI-specific arguments (model, max_tokens, temperature, etc.).
49
- """
50
- api_kwargs = {}
51
- # Tool schemas (moved from base)
52
- if self.tools_adapter:
53
- try:
54
- from janito.providers.openai.schema_generator import (
55
- generate_tool_schemas,
56
- )
57
-
58
- tool_classes = self.tools_adapter.get_tool_classes()
59
- tool_schemas = generate_tool_schemas(tool_classes)
60
- if tool_schemas: # Only add tools if we have actual schemas
61
- api_kwargs["tools"] = tool_schemas
62
- except Exception as e:
63
- # Don't add empty tools array - some providers reject it
64
- if hasattr(config, "verbose_api") and config.verbose_api:
65
- print(f"[OpenAIModelDriver] Tool schema generation failed: {e}")
66
- # OpenAI-specific parameters
67
- if config.model:
68
- api_kwargs["model"] = config.model
69
- # Prefer max_completion_tokens if present, else fallback to max_tokens (for backward compatibility)
70
- # Skip max_completion_tokens for Mistral as their API doesn't support it
71
- is_mistral = config.base_url and "mistral.ai" in str(config.base_url)
72
- if not is_mistral:
73
- if (
74
- hasattr(config, "max_completion_tokens")
75
- and config.max_completion_tokens is not None
76
- ):
77
- api_kwargs["max_completion_tokens"] = int(config.max_completion_tokens)
78
- elif hasattr(config, "max_tokens") and config.max_tokens is not None:
79
- # For models that do not support 'max_tokens', map to 'max_completion_tokens'
80
- api_kwargs["max_completion_tokens"] = int(config.max_tokens)
81
- elif hasattr(config, "max_tokens") and config.max_tokens is not None:
82
- # For Mistral, use max_tokens directly
83
- api_kwargs["max_tokens"] = int(config.max_tokens)
84
- for p in (
85
- "temperature",
86
- "top_p",
87
- "presence_penalty",
88
- "frequency_penalty",
89
- "stop",
90
- "reasoning_effort",
91
- ):
92
- v = getattr(config, p, None)
93
- if v is not None:
94
- api_kwargs[p] = v
95
- api_kwargs["messages"] = conversation
96
- api_kwargs["stream"] = False
97
- # Always return the prepared kwargs, even if no tools are registered. The
98
- # OpenAI Python SDK expects a **mapping** – passing *None* will raise
99
- # ``TypeError: argument after ** must be a mapping, not NoneType``.
100
- return api_kwargs
101
-
102
- def _call_api(self, driver_input: DriverInput):
103
- """Call the OpenAI-compatible chat completion endpoint with retry and error handling."""
104
- cancel_event = getattr(driver_input, "cancel_event", None)
105
- config = driver_input.config
106
- conversation = self.convert_history_to_api_messages(
107
- driver_input.conversation_history
108
- )
109
- request_id = getattr(config, "request_id", None)
110
- self._print_api_call_start(config)
111
- client = self._instantiate_openai_client(config)
112
- api_kwargs = self._prepare_api_kwargs(config, conversation)
113
- max_retries = getattr(config, "max_retries", 3)
114
- attempt = 1
115
- while True:
116
- try:
117
- self._print_api_attempt(config, attempt, max_retries, api_kwargs)
118
- if self._check_cancel(cancel_event, request_id, before_call=True):
119
- return None
120
- result = client.chat.completions.create(**api_kwargs)
121
- if self._check_cancel(cancel_event, request_id, before_call=False):
122
- return None
123
- self._handle_api_success(config, result, request_id)
124
- return result
125
- except Exception as e:
126
- if self._handle_api_exception(
127
- e, config, api_kwargs, attempt, max_retries, request_id
128
- ):
129
- attempt += 1
130
- continue
131
- raise
132
-
133
- def _print_api_call_start(self, config):
134
- if getattr(config, "verbose_api", False):
135
- tool_adapter_name = (
136
- type(self.tools_adapter).__name__ if self.tools_adapter else None
137
- )
138
- tool_names = []
139
- if self.tools_adapter and hasattr(self.tools_adapter, "list_tools"):
140
- try:
141
- tool_names = self.tools_adapter.list_tools()
142
- except Exception:
143
- tool_names = ["<error retrieving tools>"]
144
- print(
145
- 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}",
146
- flush=True,
147
- )
148
-
149
- def _print_api_attempt(self, config, attempt, max_retries, api_kwargs):
150
- if getattr(config, "verbose_api", False):
151
- print(
152
- f"[OpenAI] API CALL (attempt {attempt}/{max_retries}): chat.completions.create(**{api_kwargs})",
153
- flush=True,
154
- )
155
-
156
- def _handle_api_success(self, config, result, request_id):
157
- self._print_verbose_result(config, result)
158
- usage_dict = self._extract_usage(result)
159
- if getattr(config, "verbose_api", False):
160
- print(
161
- f"[OpenAI][DEBUG] Attaching usage info to RequestFinished: {usage_dict}",
162
- flush=True,
163
- )
164
- self.output_queue.put(
165
- RequestFinished(
166
- driver_name=self.__class__.__name__,
167
- request_id=request_id,
168
- response=result,
169
- status=RequestStatus.SUCCESS,
170
- usage=usage_dict,
171
- )
172
- )
173
- if getattr(config, "verbose_api", False):
174
- pretty.install()
175
- print("[OpenAI] API RESPONSE:", flush=True)
176
- pretty.pprint(result)
177
-
178
- def _handle_api_exception(
179
- self, e, config, api_kwargs, attempt, max_retries, request_id
180
- ):
181
- status_code = getattr(e, "status_code", None)
182
- err_str = str(e)
183
- lower_err = err_str.lower()
184
- is_insufficient_quota = (
185
- "insufficient_quota" in lower_err
186
- or "exceeded your current quota" in lower_err
187
- )
188
- is_rate_limit = (
189
- status_code == 429
190
- or "error code: 429" in lower_err
191
- or "resource_exhausted" in lower_err
192
- ) and not is_insufficient_quota
193
- if not is_rate_limit or attempt > max_retries:
194
- self._handle_fatal_exception(e, config, api_kwargs)
195
- retry_delay = self._extract_retry_delay_seconds(e)
196
- if retry_delay is None:
197
- retry_delay = min(2 ** (attempt - 1), 30)
198
- self.output_queue.put(
199
- RateLimitRetry(
200
- driver_name=self.__class__.__name__,
201
- request_id=request_id,
202
- attempt=attempt,
203
- retry_delay=retry_delay,
204
- error=err_str,
205
- details={},
206
- )
207
- )
208
- if getattr(config, "verbose_api", False):
209
- print(
210
- f"[OpenAI][RateLimit] Attempt {attempt}/{max_retries} failed with rate-limit. Waiting {retry_delay}s before retry.",
211
- flush=True,
212
- )
213
- start_wait = time.time()
214
- while time.time() - start_wait < retry_delay:
215
- if self._check_cancel(
216
- getattr(config, "cancel_event", None), request_id, before_call=False
217
- ):
218
- return False
219
- time.sleep(0.1)
220
- return True
221
-
222
- def _extract_retry_delay_seconds(self, exception) -> float | None:
223
- """Extract the retry delay in seconds from the provider error response.
224
-
225
- Handles both the Google Gemini style ``RetryInfo`` protobuf (where it's a
226
- ``retryDelay: '41s'`` string in JSON) and any number found after the word
227
- ``retryDelay``. Returns ``None`` if no delay could be parsed.
228
- """
229
- try:
230
- # Some SDKs expose the raw response JSON on e.args[0]
231
- if hasattr(exception, "response") and hasattr(exception.response, "text"):
232
- payload = exception.response.text
233
- else:
234
- payload = str(exception)
235
- # Look for 'retryDelay': '41s' or similar
236
- m = re.search(
237
- r"retryDelay['\"]?\s*[:=]\s*['\"]?(\d+(?:\.\d+)?)(s)?", payload
238
- )
239
- if m:
240
- return float(m.group(1))
241
- # Fallback: generic number of seconds in the message
242
- m2 = re.search(r"(\d+(?:\.\d+)?)\s*s(?:econds)?", payload)
243
- if m2:
244
- return float(m2.group(1))
245
- except Exception:
246
- pass
247
- return None
248
-
249
- def _handle_fatal_exception(self, e, config, api_kwargs):
250
- """Common path for unrecoverable exceptions.
251
-
252
- Prints diagnostics (respecting ``verbose_api``) then re-raises the
253
- exception so standard error handling in ``LLMDriver`` continues.
254
- """
255
- is_verbose = getattr(config, "verbose_api", False)
256
- if is_verbose:
257
- print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
258
- print(f"[ERROR] config: {config}", flush=True)
259
- print(
260
- f"[ERROR] api_kwargs: {api_kwargs if 'api_kwargs' in locals() else 'N/A'}",
261
- flush=True,
262
- )
263
- print("[ERROR] Full stack trace:", flush=True)
264
- print(traceback.format_exc(), flush=True)
265
- raise
266
-
267
- def _instantiate_openai_client(self, config):
268
- try:
269
- if not config.api_key:
270
- provider_name = getattr(self, "provider_name", "OpenAI-compatible")
271
- from janito.llm.auth_utils import handle_missing_api_key
272
-
273
- handle_missing_api_key(
274
- provider_name, f"{provider_name.upper()}_API_KEY"
275
- )
276
-
277
- api_key_display = str(config.api_key)
278
- if api_key_display and len(api_key_display) > 8:
279
- api_key_display = api_key_display[:4] + "..." + api_key_display[-4:]
280
- client_kwargs = {"api_key": config.api_key}
281
- if getattr(config, "base_url", None):
282
- client_kwargs["base_url"] = config.base_url
283
-
284
- # HTTP debug wrapper
285
- if os.environ.get("OPENAI_DEBUG_HTTP", "0") == "1":
286
- from http.client import HTTPConnection
287
-
288
- HTTPConnection.debuglevel = 1
289
- logging.basicConfig()
290
- logging.getLogger().setLevel(logging.DEBUG)
291
- requests_log = logging.getLogger("http.client")
292
- requests_log.setLevel(logging.DEBUG)
293
- requests_log.propagate = True
294
- print(
295
- "[OpenAIModelDriver] HTTP debug enabled via OPENAI_DEBUG_HTTP=1",
296
- flush=True,
297
- )
298
-
299
- client = openai.OpenAI(**client_kwargs)
300
- return client
301
- except Exception as e:
302
- print(
303
- f"[ERROR] Exception during OpenAI client instantiation: {e}", flush=True
304
- )
305
- print(traceback.format_exc(), flush=True)
306
- raise
307
-
308
- def _check_cancel(self, cancel_event, request_id, before_call=True):
309
- if cancel_event is not None and cancel_event.is_set():
310
- status = RequestStatus.CANCELLED
311
- reason = (
312
- "Cancelled before API call"
313
- if before_call
314
- else "Cancelled during API call"
315
- )
316
- self.output_queue.put(
317
- RequestFinished(
318
- driver_name=self.__class__.__name__,
319
- request_id=request_id,
320
- status=status,
321
- reason=reason,
322
- )
323
- )
324
- return True
325
- return False
326
-
327
- def _print_verbose_result(self, config, result):
328
- if config.verbose_api:
329
- print("[OpenAI] API RAW RESULT:", flush=True)
330
- pretty.pprint(result)
331
- if hasattr(result, "__dict__"):
332
- print("[OpenAI] API RESULT __dict__:", flush=True)
333
- pretty.pprint(result.__dict__)
334
- try:
335
- print("[OpenAI] API RESULT as dict:", dict(result), flush=True)
336
- except Exception:
337
- pass
338
- print(
339
- f"[OpenAI] API RESULT .usage: {getattr(result, 'usage', None)}",
340
- flush=True,
341
- )
342
- try:
343
- print(f"[OpenAI] API RESULT ['usage']: {result['usage']}", flush=True)
344
- except Exception:
345
- pass
346
- if not hasattr(result, "usage") or getattr(result, "usage", None) is None:
347
- print(
348
- "[OpenAI][WARNING] No usage info found in API response.", flush=True
349
- )
350
-
351
- def _extract_usage(self, result):
352
- usage = getattr(result, "usage", None)
353
- if usage is not None:
354
- usage_dict = self._usage_to_dict(usage)
355
- if usage_dict is None:
356
- print(
357
- "[OpenAI][WARNING] Could not convert usage to dict, using string fallback.",
358
- flush=True,
359
- )
360
- usage_dict = str(usage)
361
- else:
362
- usage_dict = self._extract_usage_from_result_dict(result)
363
- return usage_dict
364
-
365
- def _usage_to_dict(self, usage):
366
- if hasattr(usage, "model_dump") and callable(getattr(usage, "model_dump")):
367
- try:
368
- return usage.model_dump()
369
- except Exception:
370
- pass
371
- if hasattr(usage, "dict") and callable(getattr(usage, "dict")):
372
- try:
373
- return usage.dict()
374
- except Exception:
375
- pass
376
- try:
377
- return dict(usage)
378
- except Exception:
379
- try:
380
- return vars(usage)
381
- except Exception:
382
- pass
383
- return None
384
-
385
- def _extract_usage_from_result_dict(self, result):
386
- try:
387
- return result["usage"]
388
- except Exception:
389
- return None
390
-
391
- def convert_history_to_api_messages(self, conversation_history):
392
- """
393
- Convert LLMConversationHistory to the list of dicts required by OpenAI's API.
394
- Handles 'tool_results' and 'tool_calls' roles for compliance.
395
- """
396
- api_messages = []
397
- for msg in conversation_history.get_history():
398
- self._append_api_message(api_messages, msg)
399
- self._replace_none_content(api_messages)
400
- return api_messages
401
-
402
- def _append_api_message(self, api_messages, msg):
403
- role = msg.get("role")
404
- content = msg.get("content")
405
- if role == "tool_results":
406
- self._handle_tool_results(api_messages, content)
407
- elif role == "tool_calls":
408
- self._handle_tool_calls(api_messages, content)
409
- else:
410
- self._handle_other_roles(api_messages, msg, role, content)
411
-
412
- def _handle_tool_results(self, api_messages, content):
413
- try:
414
- results = json.loads(content) if isinstance(content, str) else content
415
- except Exception:
416
- results = [content]
417
- for result in results:
418
- if isinstance(result, dict):
419
- api_messages.append(
420
- {
421
- "role": "tool",
422
- "content": result.get("content", ""),
423
- "name": result.get("name", ""),
424
- "tool_call_id": result.get("tool_call_id", ""),
425
- }
426
- )
427
- else:
428
- api_messages.append(
429
- {
430
- "role": "tool",
431
- "content": str(result),
432
- "name": "",
433
- "tool_call_id": "",
434
- }
435
- )
436
-
437
- def _handle_tool_calls(self, api_messages, content):
438
- try:
439
- tool_calls = json.loads(content) if isinstance(content, str) else content
440
- except Exception:
441
- tool_calls = []
442
- api_messages.append(
443
- {"role": "assistant", "content": "", "tool_calls": tool_calls}
444
- )
445
-
446
- def _handle_other_roles(self, api_messages, msg, role, content):
447
- if role == "function":
448
- name = ""
449
- if isinstance(msg, dict):
450
- metadata = msg.get("metadata", {})
451
- name = metadata.get("name", "") if isinstance(metadata, dict) else ""
452
- api_messages.append({"role": "tool", "content": content, "name": name})
453
- else:
454
- api_messages.append(msg)
455
-
456
- def _replace_none_content(self, api_messages):
457
- for m in api_messages:
458
- if m.get("content", None) is None:
459
- m["content"] = ""
460
-
461
- def _convert_completion_message_to_parts(self, message):
462
- """
463
- Convert an OpenAI completion message object to a list of MessagePart objects.
464
- Handles text, tool calls, and can be extended for other types.
465
- """
466
- parts = []
467
- # Text content
468
- content = getattr(message, "content", None)
469
- if content:
470
- parts.append(TextMessagePart(content=content))
471
- # Tool calls
472
- tool_calls = getattr(message, "tool_calls", None) or []
473
- for tool_call in tool_calls:
474
- parts.append(
475
- FunctionCallMessagePart(
476
- tool_call_id=getattr(tool_call, "id", ""),
477
- function=getattr(tool_call, "function", None),
478
- )
479
- )
480
- # Extend here for other message part types if needed
481
- 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
+ try:
16
+ import openai
17
+ except ImportError:
18
+ openai = None
19
+
20
+
21
+ class OpenAIModelDriver(LLMDriver):
22
+ # Check if required dependencies are available
23
+ try:
24
+ import openai
25
+
26
+ available = True
27
+ unavailable_reason = None
28
+ except ImportError as e:
29
+ available = False
30
+ unavailable_reason = f"Missing dependency: {str(e)}"
31
+
32
+ def _get_message_from_result(self, result):
33
+ """Extract the message object from the provider result (OpenAI-specific)."""
34
+ if hasattr(result, "choices") and result.choices:
35
+ return result.choices[0].message
36
+ return None
37
+
38
+ """
39
+ OpenAI LLM driver (threaded, queue-based, stateless). Uses input/output queues accessible via instance attributes.
40
+ """
41
+
42
+ def __init__(self, tools_adapter=None, provider_name=None):
43
+ super().__init__(tools_adapter=tools_adapter, provider_name=provider_name)
44
+
45
+ def _prepare_api_kwargs(self, config, conversation):
46
+ """
47
+ Prepares API kwargs for OpenAI, including tool schemas if tools_adapter is present,
48
+ and OpenAI-specific arguments (model, max_tokens, temperature, etc.).
49
+ """
50
+ api_kwargs = {}
51
+ # Tool schemas (moved from base)
52
+ if self.tools_adapter:
53
+ try:
54
+ from janito.providers.openai.schema_generator import (
55
+ generate_tool_schemas,
56
+ )
57
+
58
+ tool_classes = self.tools_adapter.get_tool_classes()
59
+ tool_schemas = generate_tool_schemas(tool_classes)
60
+ if tool_schemas: # Only add tools if we have actual schemas
61
+ api_kwargs["tools"] = tool_schemas
62
+ except Exception as e:
63
+ # Don't add empty tools array - some providers reject it
64
+ if hasattr(config, "verbose_api") and config.verbose_api:
65
+ print(f"[OpenAIModelDriver] Tool schema generation failed: {e}")
66
+ # OpenAI-specific parameters
67
+ if config.model:
68
+ api_kwargs["model"] = config.model
69
+ # Prefer max_completion_tokens if present, else fallback to max_tokens (for backward compatibility)
70
+ # Skip max_completion_tokens for Mistral as their API doesn't support it
71
+ is_mistral = config.base_url and "mistral.ai" in str(config.base_url)
72
+ if not is_mistral:
73
+ if (
74
+ hasattr(config, "max_completion_tokens")
75
+ and config.max_completion_tokens is not None
76
+ ):
77
+ api_kwargs["max_completion_tokens"] = int(config.max_completion_tokens)
78
+ elif hasattr(config, "max_tokens") and config.max_tokens is not None:
79
+ # For models that do not support 'max_tokens', map to 'max_completion_tokens'
80
+ api_kwargs["max_completion_tokens"] = int(config.max_tokens)
81
+ elif hasattr(config, "max_tokens") and config.max_tokens is not None:
82
+ # For Mistral, use max_tokens directly
83
+ api_kwargs["max_tokens"] = int(config.max_tokens)
84
+ for p in (
85
+ "temperature",
86
+ "top_p",
87
+ "presence_penalty",
88
+ "frequency_penalty",
89
+ "stop",
90
+ "reasoning_effort",
91
+ ):
92
+ v = getattr(config, p, None)
93
+ if v is not None:
94
+ api_kwargs[p] = v
95
+ api_kwargs["messages"] = conversation
96
+ api_kwargs["stream"] = False
97
+ # Always return the prepared kwargs, even if no tools are registered. The
98
+ # OpenAI Python SDK expects a **mapping** – passing *None* will raise
99
+ # ``TypeError: argument after ** must be a mapping, not NoneType``.
100
+ return api_kwargs
101
+
102
+ def _call_api(self, driver_input: DriverInput):
103
+ """Call the OpenAI-compatible chat completion endpoint with retry and error handling."""
104
+ cancel_event = getattr(driver_input, "cancel_event", None)
105
+ config = driver_input.config
106
+ conversation = self.convert_history_to_api_messages(
107
+ driver_input.conversation_history
108
+ )
109
+ request_id = getattr(config, "request_id", None)
110
+ self._print_api_call_start(config)
111
+ client = self._instantiate_openai_client(config)
112
+ api_kwargs = self._prepare_api_kwargs(config, conversation)
113
+ max_retries = getattr(config, "max_retries", 3)
114
+ attempt = 1
115
+ while True:
116
+ try:
117
+ self._print_api_attempt(config, attempt, max_retries, api_kwargs)
118
+ if self._check_cancel(cancel_event, request_id, before_call=True):
119
+ return None
120
+ result = client.chat.completions.create(**api_kwargs)
121
+ if self._check_cancel(cancel_event, request_id, before_call=False):
122
+ return None
123
+ self._handle_api_success(config, result, request_id)
124
+ return result
125
+ except Exception as e:
126
+ if self._handle_api_exception(
127
+ e, config, api_kwargs, attempt, max_retries, request_id
128
+ ):
129
+ attempt += 1
130
+ continue
131
+ raise
132
+
133
+ def _print_api_call_start(self, config):
134
+ if getattr(config, "verbose_api", False):
135
+ tool_adapter_name = (
136
+ type(self.tools_adapter).__name__ if self.tools_adapter else None
137
+ )
138
+ tool_names = []
139
+ if self.tools_adapter and hasattr(self.tools_adapter, "list_tools"):
140
+ try:
141
+ tool_names = self.tools_adapter.list_tools()
142
+ except Exception:
143
+ tool_names = ["<error retrieving tools>"]
144
+ print(
145
+ 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}",
146
+ flush=True,
147
+ )
148
+
149
+ def _print_api_attempt(self, config, attempt, max_retries, api_kwargs):
150
+ if getattr(config, "verbose_api", False):
151
+ print(
152
+ f"[OpenAI] API CALL (attempt {attempt}/{max_retries}): chat.completions.create(**{api_kwargs})",
153
+ flush=True,
154
+ )
155
+
156
+ def _handle_api_success(self, config, result, request_id):
157
+ self._print_verbose_result(config, result)
158
+ usage_dict = self._extract_usage(result)
159
+ if getattr(config, "verbose_api", False):
160
+ print(
161
+ f"[OpenAI][DEBUG] Attaching usage info to RequestFinished: {usage_dict}",
162
+ flush=True,
163
+ )
164
+ self.output_queue.put(
165
+ RequestFinished(
166
+ driver_name=self.__class__.__name__,
167
+ request_id=request_id,
168
+ response=result,
169
+ status=RequestStatus.SUCCESS,
170
+ usage=usage_dict,
171
+ )
172
+ )
173
+ if getattr(config, "verbose_api", False):
174
+ pretty.install()
175
+ print("[OpenAI] API RESPONSE:", flush=True)
176
+ pretty.pprint(result)
177
+
178
+ def _handle_api_exception(
179
+ self, e, config, api_kwargs, attempt, max_retries, request_id
180
+ ):
181
+ status_code = getattr(e, "status_code", None)
182
+ err_str = str(e)
183
+ lower_err = err_str.lower()
184
+ is_insufficient_quota = (
185
+ "insufficient_quota" in lower_err
186
+ or "exceeded your current quota" in lower_err
187
+ )
188
+ is_rate_limit = (
189
+ status_code == 429
190
+ or "error code: 429" in lower_err
191
+ or "resource_exhausted" in lower_err
192
+ ) and not is_insufficient_quota
193
+ if not is_rate_limit or attempt > max_retries:
194
+ self._handle_fatal_exception(e, config, api_kwargs)
195
+ retry_delay = self._extract_retry_delay_seconds(e)
196
+ if retry_delay is None:
197
+ retry_delay = min(2 ** (attempt - 1), 30)
198
+ self.output_queue.put(
199
+ RateLimitRetry(
200
+ driver_name=self.__class__.__name__,
201
+ request_id=request_id,
202
+ attempt=attempt,
203
+ retry_delay=retry_delay,
204
+ error=err_str,
205
+ details={},
206
+ )
207
+ )
208
+ if getattr(config, "verbose_api", False):
209
+ print(
210
+ f"[OpenAI][RateLimit] Attempt {attempt}/{max_retries} failed with rate-limit. Waiting {retry_delay}s before retry.",
211
+ flush=True,
212
+ )
213
+ start_wait = time.time()
214
+ while time.time() - start_wait < retry_delay:
215
+ if self._check_cancel(
216
+ getattr(config, "cancel_event", None), request_id, before_call=False
217
+ ):
218
+ return False
219
+ time.sleep(0.1)
220
+ return True
221
+
222
+ def _extract_retry_delay_seconds(self, exception) -> float | None:
223
+ """Extract the retry delay in seconds from the provider error response.
224
+
225
+ Handles both the Google Gemini style ``RetryInfo`` protobuf (where it's a
226
+ ``retryDelay: '41s'`` string in JSON) and any number found after the word
227
+ ``retryDelay``. Returns ``None`` if no delay could be parsed.
228
+ """
229
+ try:
230
+ # Some SDKs expose the raw response JSON on e.args[0]
231
+ if hasattr(exception, "response") and hasattr(exception.response, "text"):
232
+ payload = exception.response.text
233
+ else:
234
+ payload = str(exception)
235
+ # Look for 'retryDelay': '41s' or similar
236
+ m = re.search(
237
+ r"retryDelay['\"]?\s*[:=]\s*['\"]?(\d+(?:\.\d+)?)(s)?", payload
238
+ )
239
+ if m:
240
+ return float(m.group(1))
241
+ # Fallback: generic number of seconds in the message
242
+ m2 = re.search(r"(\d+(?:\.\d+)?)\s*s(?:econds)?", payload)
243
+ if m2:
244
+ return float(m2.group(1))
245
+ except Exception:
246
+ pass
247
+ return None
248
+
249
+ def _handle_fatal_exception(self, e, config, api_kwargs):
250
+ """Common path for unrecoverable exceptions.
251
+
252
+ Prints diagnostics (respecting ``verbose_api``) then re-raises the
253
+ exception so standard error handling in ``LLMDriver`` continues.
254
+ """
255
+ is_verbose = getattr(config, "verbose_api", False)
256
+ if is_verbose:
257
+ print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
258
+ print(f"[ERROR] config: {config}", flush=True)
259
+ print(
260
+ f"[ERROR] api_kwargs: {api_kwargs if 'api_kwargs' in locals() else 'N/A'}",
261
+ flush=True,
262
+ )
263
+ print("[ERROR] Full stack trace:", flush=True)
264
+ print(traceback.format_exc(), flush=True)
265
+ raise
266
+
267
+ def _instantiate_openai_client(self, config):
268
+ try:
269
+ if not config.api_key:
270
+ provider_name = getattr(self, "provider_name", "OpenAI-compatible")
271
+ from janito.llm.auth_utils import handle_missing_api_key
272
+
273
+ handle_missing_api_key(
274
+ provider_name, f"{provider_name.upper()}_API_KEY"
275
+ )
276
+
277
+ api_key_display = str(config.api_key)
278
+ if api_key_display and len(api_key_display) > 8:
279
+ api_key_display = api_key_display[:4] + "..." + api_key_display[-4:]
280
+ client_kwargs = {"api_key": config.api_key}
281
+ # Check for BASE_URL environment variable first, then fall back to config
282
+ base_url = os.environ.get("BASE_URL") or getattr(config, "base_url", None)
283
+ if base_url:
284
+ client_kwargs["base_url"] = base_url
285
+
286
+ # HTTP debug wrapper
287
+ if os.environ.get("OPENAI_DEBUG_HTTP", "0") == "1" or getattr(
288
+ config, "verbose_http", False
289
+ ):
290
+ from http.client import HTTPConnection
291
+
292
+ HTTPConnection.debuglevel = 1
293
+ logging.basicConfig()
294
+ logging.getLogger().setLevel(logging.DEBUG)
295
+ requests_log = logging.getLogger("http.client")
296
+ requests_log.setLevel(logging.DEBUG)
297
+ requests_log.propagate = True
298
+ print(
299
+ "[OpenAIModelDriver] HTTP debug enabled via OPENAI_DEBUG_HTTP=1 or --verbose-http",
300
+ flush=True,
301
+ )
302
+
303
+ client = openai.OpenAI(**client_kwargs)
304
+ return client
305
+ except Exception as e:
306
+ print(
307
+ f"[ERROR] Exception during OpenAI client instantiation: {e}", flush=True
308
+ )
309
+ print(traceback.format_exc(), flush=True)
310
+ raise
311
+
312
+ def _check_cancel(self, cancel_event, request_id, before_call=True):
313
+ if cancel_event is not None and cancel_event.is_set():
314
+ status = RequestStatus.CANCELLED
315
+ reason = (
316
+ "Cancelled before API call"
317
+ if before_call
318
+ else "Cancelled during API call"
319
+ )
320
+ self.output_queue.put(
321
+ RequestFinished(
322
+ driver_name=self.__class__.__name__,
323
+ request_id=request_id,
324
+ status=status,
325
+ reason=reason,
326
+ )
327
+ )
328
+ return True
329
+ return False
330
+
331
+ def _print_verbose_result(self, config, result):
332
+ if config.verbose_api:
333
+ print("[OpenAI] API RAW RESULT:", flush=True)
334
+ pretty.pprint(result)
335
+ if hasattr(result, "__dict__"):
336
+ print("[OpenAI] API RESULT __dict__:", flush=True)
337
+ pretty.pprint(result.__dict__)
338
+ try:
339
+ print("[OpenAI] API RESULT as dict:", dict(result), flush=True)
340
+ except Exception:
341
+ pass
342
+ print(
343
+ f"[OpenAI] API RESULT .usage: {getattr(result, 'usage', None)}",
344
+ flush=True,
345
+ )
346
+ try:
347
+ print(f"[OpenAI] API RESULT ['usage']: {result['usage']}", flush=True)
348
+ except Exception:
349
+ pass
350
+ if not hasattr(result, "usage") or getattr(result, "usage", None) is None:
351
+ print(
352
+ "[OpenAI][WARNING] No usage info found in API response.", flush=True
353
+ )
354
+
355
+ def _extract_usage(self, result):
356
+ usage = getattr(result, "usage", None)
357
+ if usage is not None:
358
+ usage_dict = self._usage_to_dict(usage)
359
+ if usage_dict is None:
360
+ print(
361
+ "[OpenAI][WARNING] Could not convert usage to dict, using string fallback.",
362
+ flush=True,
363
+ )
364
+ usage_dict = str(usage)
365
+ else:
366
+ usage_dict = self._extract_usage_from_result_dict(result)
367
+ return usage_dict
368
+
369
+ def _usage_to_dict(self, usage):
370
+ if hasattr(usage, "model_dump") and callable(getattr(usage, "model_dump")):
371
+ try:
372
+ return usage.model_dump()
373
+ except Exception:
374
+ pass
375
+ if hasattr(usage, "dict") and callable(getattr(usage, "dict")):
376
+ try:
377
+ return usage.dict()
378
+ except Exception:
379
+ pass
380
+ try:
381
+ return dict(usage)
382
+ except Exception:
383
+ try:
384
+ return vars(usage)
385
+ except Exception:
386
+ pass
387
+ return None
388
+
389
+ def _extract_usage_from_result_dict(self, result):
390
+ try:
391
+ return result["usage"]
392
+ except Exception:
393
+ return None
394
+
395
+ def convert_history_to_api_messages(self, conversation_history):
396
+ """
397
+ Convert LLMConversationHistory to the list of dicts required by OpenAI's API.
398
+ Handles 'tool_results' and 'tool_calls' roles for compliance.
399
+ """
400
+ api_messages = []
401
+ for msg in conversation_history.get_history():
402
+ self._append_api_message(api_messages, msg)
403
+ self._replace_none_content(api_messages)
404
+ return api_messages
405
+
406
+ def _append_api_message(self, api_messages, msg):
407
+ role = msg.get("role")
408
+ content = msg.get("content")
409
+ if role == "tool_results":
410
+ self._handle_tool_results(api_messages, content)
411
+ elif role == "tool_calls":
412
+ self._handle_tool_calls(api_messages, content)
413
+ else:
414
+ self._handle_other_roles(api_messages, msg, role, content)
415
+
416
+ def _handle_tool_results(self, api_messages, content):
417
+ try:
418
+ results = json.loads(content) if isinstance(content, str) else content
419
+ except Exception:
420
+ results = [content]
421
+ for result in results:
422
+ if isinstance(result, dict):
423
+ api_messages.append(
424
+ {
425
+ "role": "tool",
426
+ "content": result.get("content", ""),
427
+ "name": result.get("name", ""),
428
+ "tool_call_id": result.get("tool_call_id", ""),
429
+ }
430
+ )
431
+ else:
432
+ api_messages.append(
433
+ {
434
+ "role": "tool",
435
+ "content": str(result),
436
+ "name": "",
437
+ "tool_call_id": "",
438
+ }
439
+ )
440
+
441
+ def _handle_tool_calls(self, api_messages, content):
442
+ try:
443
+ tool_calls = json.loads(content) if isinstance(content, str) else content
444
+ except Exception:
445
+ tool_calls = []
446
+ api_messages.append(
447
+ {"role": "assistant", "content": "", "tool_calls": tool_calls}
448
+ )
449
+
450
+ def _handle_other_roles(self, api_messages, msg, role, content):
451
+ if role == "function":
452
+ name = ""
453
+ if isinstance(msg, dict):
454
+ metadata = msg.get("metadata", {})
455
+ name = metadata.get("name", "") if isinstance(metadata, dict) else ""
456
+ api_messages.append({"role": "tool", "content": content, "name": name})
457
+ else:
458
+ api_messages.append(msg)
459
+
460
+ def _replace_none_content(self, api_messages):
461
+ for m in api_messages:
462
+ if m.get("content", None) is None:
463
+ m["content"] = ""
464
+
465
+ def _convert_completion_message_to_parts(self, message):
466
+ """
467
+ Convert an OpenAI completion message object to a list of MessagePart objects.
468
+ Handles text, tool calls, and can be extended for other types.
469
+ """
470
+ parts = []
471
+ # Text content
472
+ content = getattr(message, "content", None)
473
+ if content:
474
+ parts.append(TextMessagePart(content=content))
475
+ # Tool calls
476
+ tool_calls = getattr(message, "tool_calls", None) or []
477
+ for tool_call in tool_calls:
478
+ parts.append(
479
+ FunctionCallMessagePart(
480
+ tool_call_id=getattr(tool_call, "id", ""),
481
+ function=getattr(tool_call, "function", None),
482
+ )
483
+ )
484
+ # Extend here for other message part types if needed
485
+ return parts