janito 2.2.0__py3-none-any.whl → 2.3.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.
Files changed (57) hide show
  1. janito/__init__.py +1 -1
  2. janito/agent/setup_agent.py +14 -5
  3. janito/agent/templates/profiles/system_prompt_template_main.txt.j2 +3 -1
  4. janito/cli/chat_mode/bindings.py +6 -0
  5. janito/cli/chat_mode/session.py +16 -0
  6. janito/cli/chat_mode/shell/commands/__init__.py +3 -0
  7. janito/cli/chat_mode/shell/commands/exec.py +27 -0
  8. janito/cli/chat_mode/shell/commands/tools.py +17 -6
  9. janito/cli/chat_mode/shell/session/manager.py +1 -0
  10. janito/cli/chat_mode/toolbar.py +1 -0
  11. janito/cli/cli_commands/model_utils.py +95 -84
  12. janito/cli/config.py +2 -1
  13. janito/cli/core/getters.py +33 -31
  14. janito/cli/core/runner.py +165 -148
  15. janito/cli/core/setters.py +5 -1
  16. janito/cli/main_cli.py +12 -1
  17. janito/cli/prompt_core.py +5 -2
  18. janito/cli/rich_terminal_reporter.py +22 -3
  19. janito/cli/single_shot_mode/handler.py +11 -1
  20. janito/cli/verbose_output.py +1 -1
  21. janito/config_manager.py +112 -110
  22. janito/driver_events.py +14 -0
  23. janito/drivers/azure_openai/driver.py +38 -3
  24. janito/drivers/driver_registry.py +0 -2
  25. janito/drivers/openai/driver.py +196 -36
  26. janito/llm/auth.py +63 -62
  27. janito/llm/driver.py +7 -1
  28. janito/llm/driver_config.py +1 -0
  29. janito/provider_config.py +7 -3
  30. janito/provider_registry.py +18 -0
  31. janito/providers/__init__.py +1 -0
  32. janito/providers/anthropic/provider.py +4 -2
  33. janito/providers/azure_openai/model_info.py +16 -15
  34. janito/providers/azure_openai/provider.py +33 -2
  35. janito/providers/deepseek/provider.py +3 -0
  36. janito/providers/google/model_info.py +21 -29
  37. janito/providers/google/provider.py +52 -38
  38. janito/providers/mistralai/provider.py +5 -2
  39. janito/providers/openai/provider.py +4 -0
  40. janito/providers/provider_static_info.py +2 -3
  41. janito/tools/adapters/local/adapter.py +33 -11
  42. janito/tools/adapters/local/delete_text_in_file.py +4 -7
  43. janito/tools/adapters/local/move_file.py +3 -13
  44. janito/tools/adapters/local/remove_directory.py +6 -17
  45. janito/tools/adapters/local/remove_file.py +4 -10
  46. janito/tools/adapters/local/replace_text_in_file.py +6 -9
  47. janito/tools/adapters/local/search_text/match_lines.py +1 -1
  48. janito/tools/tools_adapter.py +78 -6
  49. janito/version.py +1 -1
  50. {janito-2.2.0.dist-info → janito-2.3.1.dist-info}/METADATA +149 -10
  51. {janito-2.2.0.dist-info → janito-2.3.1.dist-info}/RECORD +55 -56
  52. janito/drivers/google_genai/driver.py +0 -54
  53. janito/drivers/google_genai/schema_generator.py +0 -67
  54. {janito-2.2.0.dist-info → janito-2.3.1.dist-info}/WHEEL +0 -0
  55. {janito-2.2.0.dist-info → janito-2.3.1.dist-info}/entry_points.txt +0 -0
  56. {janito-2.2.0.dist-info → janito-2.3.1.dist-info}/licenses/LICENSE +0 -0
  57. {janito-2.2.0.dist-info → janito-2.3.1.dist-info}/top_level.txt +0 -0
janito/config_manager.py CHANGED
@@ -1,110 +1,112 @@
1
- import json
2
- from pathlib import Path
3
- from threading import Lock
4
-
5
-
6
- class ConfigManager:
7
- """
8
- Unified configuration manager supporting:
9
- - Defaults
10
- - File-based configuration
11
- - Runtime overrides (e.g., CLI args)
12
- """
13
-
14
- _instance = None
15
- _lock = Lock()
16
-
17
- def __new__(cls, *args, **kwargs):
18
- with cls._lock:
19
- if not cls._instance:
20
- cls._instance = super(ConfigManager, cls).__new__(cls)
21
- return cls._instance
22
-
23
- def __init__(self, config_path=None, defaults=None, runtime_overrides=None):
24
- # Lazy single-init
25
- if hasattr(self, "_initialized") and self._initialized:
26
- return
27
- self._initialized = True
28
-
29
- self.config_path = Path(config_path or Path.home() / ".janito" / "config.json")
30
- self.defaults = dict(defaults) if defaults else {}
31
- self.file_config = {}
32
- self.runtime_overrides = dict(runtime_overrides) if runtime_overrides else {}
33
- self._load_file_config()
34
-
35
- def _load_file_config(self):
36
- if self.config_path.exists():
37
- with open(self.config_path, "r", encoding="utf-8") as f:
38
- try:
39
- self.file_config = json.load(f)
40
- except Exception:
41
- self.file_config = {}
42
- else:
43
- self.file_config = {}
44
-
45
- def save(self):
46
- self.config_path.parent.mkdir(parents=True, exist_ok=True)
47
- with open(self.config_path, "w", encoding="utf-8") as f:
48
- json.dump(self.file_config, f, indent=2)
49
-
50
- def get(self, key, default=None):
51
- # Precedence: runtime_overrides > file_config > defaults
52
- for layer in (self.runtime_overrides, self.file_config, self.defaults):
53
- if key in layer and layer[key] is not None:
54
- return layer[key]
55
- return default
56
-
57
- def runtime_set(self, key, value):
58
- self.runtime_overrides[key] = value
59
-
60
- def file_set(self, key, value):
61
- # Always reload, update, and persist
62
- self._load_file_config()
63
- self.file_config[key] = value
64
- with open(self.config_path, "w", encoding="utf-8") as f:
65
- json.dump(self.file_config, f, indent=2)
66
-
67
- def all(self, layered=False):
68
- merged = dict(self.defaults)
69
- merged.update(self.file_config)
70
- merged.update(self.runtime_overrides)
71
- if layered:
72
- # Only file+runtime, i.e., what is saved to disk
73
- d = dict(self.file_config)
74
- d.update(self.runtime_overrides)
75
- return d
76
- return merged
77
-
78
- # Namespaced provider/model config
79
- def get_provider_config(self, provider, default=None):
80
- providers = self.file_config.get("providers") or {}
81
- return providers.get(provider) or (default or {})
82
-
83
- def set_provider_config(self, provider, key, value):
84
- if "providers" not in self.file_config:
85
- self.file_config["providers"] = {}
86
- if provider not in self.file_config["providers"]:
87
- self.file_config["providers"][provider] = {}
88
- self.file_config["providers"][provider][key] = value
89
-
90
- def get_provider_model_config(self, provider, model, default=None):
91
- return (
92
- self.file_config.get("providers")
93
- or {}.get(provider, {}).get("models", {}).get(model)
94
- or (default or {})
95
- )
96
-
97
- def set_provider_model_config(self, provider, model, key, value):
98
- if "providers" not in self.file_config:
99
- self.file_config["providers"] = {}
100
- if provider not in self.file_config["providers"]:
101
- self.file_config["providers"][provider] = {}
102
- if "models" not in self.file_config["providers"][provider]:
103
- self.file_config["providers"][provider]["models"] = {}
104
- if model not in self.file_config["providers"][provider]["models"]:
105
- self.file_config["providers"][provider]["models"][model] = {}
106
- self.file_config["providers"][provider]["models"][model][key] = value
107
-
108
- # Support loading runtime overrides after init (e.g. after parsing CLI args)
109
- def apply_runtime_overrides(self, overrides_dict):
110
- self.runtime_overrides.update(overrides_dict)
1
+ import json
2
+ from pathlib import Path
3
+ from threading import Lock
4
+
5
+
6
+ class ConfigManager:
7
+ """
8
+ Unified configuration manager supporting:
9
+ - Defaults
10
+ - File-based configuration
11
+ - Runtime overrides (e.g., CLI args)
12
+ """
13
+
14
+ _instance = None
15
+ _lock = Lock()
16
+
17
+ def __new__(cls, *args, **kwargs):
18
+ with cls._lock:
19
+ if not cls._instance:
20
+ cls._instance = super(ConfigManager, cls).__new__(cls)
21
+ return cls._instance
22
+
23
+ def __init__(self, config_path=None, defaults=None, runtime_overrides=None):
24
+ # Lazy single-init
25
+ if hasattr(self, "_initialized") and self._initialized:
26
+ return
27
+ self._initialized = True
28
+
29
+ self.config_path = Path(config_path or Path.home() / ".janito" / "config.json")
30
+ self.defaults = dict(defaults) if defaults else {}
31
+ self.file_config = {}
32
+ self.runtime_overrides = dict(runtime_overrides) if runtime_overrides else {}
33
+ self._load_file_config()
34
+
35
+ def _load_file_config(self):
36
+ if self.config_path.exists():
37
+ with open(self.config_path, "r", encoding="utf-8") as f:
38
+ try:
39
+ self.file_config = json.load(f)
40
+ except Exception:
41
+ self.file_config = {}
42
+ else:
43
+ self.file_config = {}
44
+
45
+ def save(self):
46
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
47
+ with open(self.config_path, "w", encoding="utf-8") as f:
48
+ json.dump(self.file_config, f, indent=2)
49
+ f.write("\n")
50
+
51
+ def get(self, key, default=None):
52
+ # Precedence: runtime_overrides > file_config > defaults
53
+ for layer in (self.runtime_overrides, self.file_config, self.defaults):
54
+ if key in layer and layer[key] is not None:
55
+ return layer[key]
56
+ return default
57
+
58
+ def runtime_set(self, key, value):
59
+ self.runtime_overrides[key] = value
60
+
61
+ def file_set(self, key, value):
62
+ # Always reload, update, and persist
63
+ self._load_file_config()
64
+ self.file_config[key] = value
65
+ with open(self.config_path, "w", encoding="utf-8") as f:
66
+ json.dump(self.file_config, f, indent=2)
67
+ f.write("\n")
68
+
69
+ def all(self, layered=False):
70
+ merged = dict(self.defaults)
71
+ merged.update(self.file_config)
72
+ merged.update(self.runtime_overrides)
73
+ if layered:
74
+ # Only file+runtime, i.e., what is saved to disk
75
+ d = dict(self.file_config)
76
+ d.update(self.runtime_overrides)
77
+ return d
78
+ return merged
79
+
80
+ # Namespaced provider/model config
81
+ def get_provider_config(self, provider, default=None):
82
+ providers = self.file_config.get("providers") or {}
83
+ return providers.get(provider) or (default or {})
84
+
85
+ def set_provider_config(self, provider, key, value):
86
+ if "providers" not in self.file_config:
87
+ self.file_config["providers"] = {}
88
+ if provider not in self.file_config["providers"]:
89
+ self.file_config["providers"][provider] = {}
90
+ self.file_config["providers"][provider][key] = value
91
+
92
+ def get_provider_model_config(self, provider, model, default=None):
93
+ return (
94
+ self.file_config.get("providers")
95
+ or {}.get(provider, {}).get("models", {}).get(model)
96
+ or (default or {})
97
+ )
98
+
99
+ def set_provider_model_config(self, provider, model, key, value):
100
+ if "providers" not in self.file_config:
101
+ self.file_config["providers"] = {}
102
+ if provider not in self.file_config["providers"]:
103
+ self.file_config["providers"][provider] = {}
104
+ if "models" not in self.file_config["providers"][provider]:
105
+ self.file_config["providers"][provider]["models"] = {}
106
+ if model not in self.file_config["providers"][provider]["models"]:
107
+ self.file_config["providers"][provider]["models"][model] = {}
108
+ self.file_config["providers"][provider]["models"][model][key] = value
109
+
110
+ # Support loading runtime overrides after init (e.g. after parsing CLI args)
111
+ def apply_runtime_overrides(self, overrides_dict):
112
+ self.runtime_overrides.update(overrides_dict)
janito/driver_events.py CHANGED
@@ -90,6 +90,20 @@ class ToolCallFinished(DriverEvent):
90
90
  return self.name
91
91
 
92
92
 
93
+ @attr.s(auto_attribs=True, kw_only=True)
94
+ @attr.s(auto_attribs=True, kw_only=True)
95
+ class RateLimitRetry(DriverEvent):
96
+ """Emitted by a driver when it encounters a provider rate-limit (HTTP 429) and
97
+ decides to retry the request after a delay. This allows UIs or logging layers
98
+ to give feedback to the user while the driver automatically waits.
99
+ """
100
+
101
+ attempt: int = 0 # Retry attempt number (starting at 1)
102
+ retry_delay: float = 0 # Delay in seconds before the next attempt
103
+ error: str = None # The original error message
104
+ details: dict = None # Additional details extracted from the provider response
105
+
106
+
93
107
  @attr.s(auto_attribs=True, kw_only=True)
94
108
  class ResponseReceived(DriverEvent):
95
109
  parts: list = None
@@ -14,6 +14,18 @@ from janito.llm.driver_config import LLMDriverConfig
14
14
 
15
15
 
16
16
  class AzureOpenAIModelDriver(OpenAIModelDriver):
17
+ def start(self, *args, **kwargs):
18
+ # Ensure azure_deployment_name is set before starting
19
+ config = getattr(self, 'config', None)
20
+ deployment_name = None
21
+ if config and hasattr(config, 'extra'):
22
+ deployment_name = config.extra.get('azure_deployment_name')
23
+ if not deployment_name:
24
+ raise RuntimeError("AzureOpenAIModelDriver requires 'azure_deployment_name' to be set in config.extra['azure_deployment_name'] before starting.")
25
+ # Call parent start if exists
26
+ if hasattr(super(), 'start'):
27
+ return super().start(*args, **kwargs)
28
+
17
29
  available = DRIVER_AVAILABLE
18
30
  unavailable_reason = DRIVER_UNAVAILABLE_REASON
19
31
 
@@ -23,17 +35,39 @@ class AzureOpenAIModelDriver(OpenAIModelDriver):
23
35
 
24
36
  required_config = {"base_url"} # Update key as used in your config logic
25
37
 
26
- def __init__(self, tools_adapter=None):
38
+ def __init__(self, tools_adapter=None, provider_name=None):
27
39
  if not self.available:
28
40
  raise ImportError(
29
41
  f"AzureOpenAIModelDriver unavailable: {self.unavailable_reason}"
30
42
  )
31
- # Do NOT call super().__init__ if Azure SDK is not available
32
- OpenAIModelDriver.__init__(self, tools_adapter=tools_adapter)
43
+ # Ensure proper parent initialization
44
+ super().__init__(tools_adapter=tools_adapter, provider_name=provider_name)
33
45
  self.azure_endpoint = None
34
46
  self.api_version = None
35
47
  self.api_key = None
36
48
 
49
+ def _prepare_api_kwargs(self, config, conversation):
50
+ """
51
+ Prepares API kwargs for Azure OpenAI, using the deployment name as the model parameter.
52
+ Also ensures tool schemas are included if tools_adapter is present.
53
+ """
54
+ api_kwargs = super()._prepare_api_kwargs(config, conversation)
55
+ deployment_name = config.extra.get("azure_deployment_name") if hasattr(config, "extra") else None
56
+ if deployment_name:
57
+ api_kwargs["model"] = deployment_name
58
+ # Patch: Ensure tools are included for Azure as for OpenAI
59
+ if self.tools_adapter:
60
+ try:
61
+ from janito.providers.openai.schema_generator import generate_tool_schemas
62
+ tool_classes = self.tools_adapter.get_tool_classes()
63
+ tool_schemas = generate_tool_schemas(tool_classes)
64
+ api_kwargs["tools"] = tool_schemas
65
+ except Exception as e:
66
+ api_kwargs["tools"] = []
67
+ if hasattr(config, "verbose_api") and config.verbose_api:
68
+ print(f"[AzureOpenAIModelDriver] Tool schema generation failed: {e}")
69
+ return api_kwargs
70
+
37
71
  def _instantiate_openai_client(self, config):
38
72
  try:
39
73
  from openai import AzureOpenAI
@@ -45,6 +79,7 @@ class AzureOpenAIModelDriver(OpenAIModelDriver):
45
79
  "azure_endpoint": getattr(config, "base_url", None),
46
80
  "api_version": config.extra.get("api_version", "2023-05-15"),
47
81
  }
82
+ # Do NOT pass azure_deployment; deployment name is used as the 'model' param in API calls
48
83
  client = AzureOpenAI(**client_kwargs)
49
84
  return client
50
85
  except Exception as e:
@@ -8,14 +8,12 @@ from typing import Dict, Type
8
8
  # --- Import driver classes ---
9
9
  from janito.drivers.anthropic.driver import AnthropicModelDriver
10
10
  from janito.drivers.azure_openai.driver import AzureOpenAIModelDriver
11
- from janito.drivers.google_genai.driver import GoogleGenaiModelDriver
12
11
  from janito.drivers.mistralai.driver import MistralAIModelDriver
13
12
  from janito.drivers.openai.driver import OpenAIModelDriver
14
13
 
15
14
  _DRIVER_REGISTRY: Dict[str, Type] = {
16
15
  "AnthropicModelDriver": AnthropicModelDriver,
17
16
  "AzureOpenAIModelDriver": AzureOpenAIModelDriver,
18
- "GoogleGenaiModelDriver": GoogleGenaiModelDriver,
19
17
  "MistralAIModelDriver": MistralAIModelDriver,
20
18
  "OpenAIModelDriver": OpenAIModelDriver,
21
19
  }
@@ -1,9 +1,10 @@
1
1
  import uuid
2
2
  import traceback
3
3
  from rich import pretty
4
+ import os
4
5
  from janito.llm.driver import LLMDriver
5
6
  from janito.llm.driver_input import DriverInput
6
- from janito.driver_events import RequestFinished, RequestStatus
7
+ from janito.driver_events import RequestFinished, RequestStatus, RateLimitRetry
7
8
 
8
9
  # Safe import of openai SDK
9
10
  try:
@@ -70,6 +71,7 @@ class OpenAIModelDriver(LLMDriver):
70
71
  "presence_penalty",
71
72
  "frequency_penalty",
72
73
  "stop",
74
+ "reasoning_effort",
73
75
  ):
74
76
  v = getattr(config, p, None)
75
77
  if v is not None:
@@ -79,6 +81,21 @@ class OpenAIModelDriver(LLMDriver):
79
81
  return api_kwargs
80
82
 
81
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
+ """
82
99
  cancel_event = getattr(driver_input, "cancel_event", None)
83
100
  config = driver_input.config
84
101
  conversation = self.convert_history_to_api_messages(
@@ -86,45 +103,172 @@ class OpenAIModelDriver(LLMDriver):
86
103
  )
87
104
  request_id = getattr(config, "request_id", None)
88
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>"]
89
113
  print(
90
- f"[verbose-api] OpenAI API call about to be sent. Model: {config.model}, max_tokens: {config.max_tokens}, tools_adapter: {type(self.tools_adapter).__name__ if self.tools_adapter else None}",
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}",
91
115
  flush=True,
92
116
  )
93
- try:
94
- client = self._instantiate_openai_client(config)
95
- api_kwargs = self._prepare_api_kwargs(config, conversation)
96
- if config.verbose_api:
97
- print(
98
- f"[OpenAI] API CALL: chat.completions.create(**{api_kwargs})",
99
- flush=True,
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
+ )
100
151
  )
101
- if self._check_cancel(cancel_event, request_id, before_call=True):
102
- return None
103
- result = client.chat.completions.create(**api_kwargs)
104
- if self._check_cancel(cancel_event, request_id, before_call=False):
105
- return None
106
- self._print_verbose_result(config, result)
107
- usage_dict = self._extract_usage(result)
108
- if config.verbose_api:
109
- print(
110
- f"[OpenAI][DEBUG] Attaching usage info to RequestFinished: {usage_dict}",
111
- flush=True,
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
112
171
  )
113
- self.output_queue.put(
114
- RequestFinished(
115
- driver_name=self.__class__.__name__,
116
- request_id=request_id,
117
- response=result,
118
- status=RequestStatus.SUCCESS,
119
- usage=usage_dict,
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
120
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
121
217
  )
122
- if config.verbose_api:
123
- pretty.install()
124
- print("[OpenAI] API RESPONSE:", flush=True)
125
- pretty.pprint(result)
126
- return result
127
- except Exception as e:
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:
128
272
  print(f"[ERROR] Exception during OpenAI API call: {e}", flush=True)
129
273
  print(f"[ERROR] config: {config}", flush=True)
130
274
  print(
@@ -132,10 +276,9 @@ class OpenAIModelDriver(LLMDriver):
132
276
  flush=True,
133
277
  )
134
278
  import traceback
135
-
136
279
  print("[ERROR] Full stack trace:", flush=True)
137
280
  print(traceback.format_exc(), flush=True)
138
- raise
281
+ raise
139
282
 
140
283
  def _instantiate_openai_client(self, config):
141
284
  try:
@@ -145,6 +288,19 @@ class OpenAIModelDriver(LLMDriver):
145
288
  client_kwargs = {"api_key": config.api_key}
146
289
  if getattr(config, "base_url", None):
147
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
+
148
304
  client = openai.OpenAI(**client_kwargs)
149
305
  return client
150
306
  except Exception as e:
@@ -289,7 +445,7 @@ class OpenAIModelDriver(LLMDriver):
289
445
  except Exception:
290
446
  tool_calls = []
291
447
  api_messages.append(
292
- {"role": "assistant", "content": None, "tool_calls": tool_calls}
448
+ {"role": "assistant", "content": "", "tool_calls": tool_calls}
293
449
  )
294
450
  else:
295
451
  # Special handling for 'function' role: extract 'name' from metadata if present
@@ -307,6 +463,10 @@ class OpenAIModelDriver(LLMDriver):
307
463
  )
308
464
  else:
309
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"] = ""
310
470
  return api_messages
311
471
 
312
472
  def _convert_completion_message_to_parts(self, message):