code-puppy 0.0.325__py3-none-any.whl → 0.0.341__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 (52) hide show
  1. code_puppy/agents/base_agent.py +110 -124
  2. code_puppy/claude_cache_client.py +208 -2
  3. code_puppy/cli_runner.py +152 -32
  4. code_puppy/command_line/add_model_menu.py +4 -0
  5. code_puppy/command_line/autosave_menu.py +23 -24
  6. code_puppy/command_line/clipboard.py +527 -0
  7. code_puppy/command_line/colors_menu.py +5 -0
  8. code_puppy/command_line/config_commands.py +24 -1
  9. code_puppy/command_line/core_commands.py +85 -0
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/install_menu.py +5 -1
  13. code_puppy/command_line/model_settings_menu.py +5 -0
  14. code_puppy/command_line/motd.py +13 -7
  15. code_puppy/command_line/onboarding_slides.py +180 -0
  16. code_puppy/command_line/onboarding_wizard.py +340 -0
  17. code_puppy/command_line/prompt_toolkit_completion.py +118 -0
  18. code_puppy/config.py +3 -2
  19. code_puppy/http_utils.py +201 -279
  20. code_puppy/keymap.py +10 -8
  21. code_puppy/mcp_/managed_server.py +7 -11
  22. code_puppy/messaging/messages.py +3 -0
  23. code_puppy/messaging/rich_renderer.py +114 -22
  24. code_puppy/model_factory.py +102 -15
  25. code_puppy/models.json +2 -2
  26. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  27. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
  29. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  30. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  31. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  32. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  33. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  34. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  35. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  36. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  37. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  38. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  39. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  40. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  41. code_puppy/reopenable_async_client.py +8 -8
  42. code_puppy/terminal_utils.py +295 -3
  43. code_puppy/tools/command_runner.py +43 -54
  44. code_puppy/tools/common.py +3 -9
  45. code_puppy/uvx_detection.py +242 -0
  46. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
  47. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
  48. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
  49. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
  50. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
  51. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
  52. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
@@ -27,6 +27,10 @@ from code_puppy.command_line.attachments import (
27
27
  _detect_path_tokens,
28
28
  _tokenise,
29
29
  )
30
+ from code_puppy.command_line.clipboard import (
31
+ capture_clipboard_image_to_pending,
32
+ has_image_in_clipboard,
33
+ )
30
34
  from code_puppy.command_line.command_registry import get_unique_commands
31
35
  from code_puppy.command_line.file_path_completion import FilePathCompleter
32
36
  from code_puppy.command_line.load_context_completion import LoadContextCompleter
@@ -644,6 +648,120 @@ async def get_input_with_combined_completion(
644
648
  else:
645
649
  event.current_buffer.validate_and_handle()
646
650
 
651
+ # Handle bracketed paste (triggered by most terminal Cmd+V / Ctrl+V)
652
+ # This is the PRIMARY paste handler - works with Cmd+V on macOS terminals
653
+ @bindings.add(Keys.BracketedPaste)
654
+ def handle_bracketed_paste(event):
655
+ """Handle bracketed paste - works with Cmd+V on macOS terminals."""
656
+ # The pasted data is in event.data
657
+ pasted_data = event.data
658
+
659
+ # Check if clipboard has an image (the pasted text might just be empty or a file path)
660
+ try:
661
+ if has_image_in_clipboard():
662
+ placeholder = capture_clipboard_image_to_pending()
663
+ if placeholder:
664
+ event.app.current_buffer.insert_text(placeholder + " ")
665
+ # The placeholder itself is visible feedback - no need for extra output
666
+ # Use bell for audible feedback (works in most terminals)
667
+ event.app.output.bell()
668
+ return # Don't also paste the text data
669
+ except Exception:
670
+ pass
671
+
672
+ # No image - insert the pasted text as normal, sanitizing Windows newlines
673
+ if pasted_data:
674
+ # Normalize Windows line endings to Unix style
675
+ sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
676
+ event.app.current_buffer.insert_text(sanitized_data)
677
+
678
+ # Fallback Ctrl+V for terminals without bracketed paste support
679
+ @bindings.add("c-v", eager=True)
680
+ def handle_smart_paste(event):
681
+ """Handle Ctrl+V - auto-detect image vs text in clipboard."""
682
+ try:
683
+ # Check for image first
684
+ if has_image_in_clipboard():
685
+ placeholder = capture_clipboard_image_to_pending()
686
+ if placeholder:
687
+ event.app.current_buffer.insert_text(placeholder + " ")
688
+ # The placeholder itself is visible feedback - no need for extra output
689
+ # Use bell for audible feedback (works in most terminals)
690
+ event.app.output.bell()
691
+ return # Don't also paste text
692
+ except Exception:
693
+ pass # Fall through to text paste on any error
694
+
695
+ # No image (or error) - do normal text paste
696
+ # prompt_toolkit doesn't have built-in paste, so we handle it manually
697
+ try:
698
+ import platform
699
+ import subprocess
700
+
701
+ text = None
702
+ system = platform.system()
703
+
704
+ if system == "Darwin": # macOS
705
+ result = subprocess.run(
706
+ ["pbpaste"], capture_output=True, text=True, timeout=2
707
+ )
708
+ if result.returncode == 0:
709
+ text = result.stdout
710
+ elif system == "Windows":
711
+ # Windows - use powershell
712
+ result = subprocess.run(
713
+ ["powershell", "-command", "Get-Clipboard"],
714
+ capture_output=True,
715
+ text=True,
716
+ timeout=2,
717
+ )
718
+ if result.returncode == 0:
719
+ text = result.stdout
720
+ else: # Linux
721
+ # Try xclip first, then xsel
722
+ for cmd in [
723
+ ["xclip", "-selection", "clipboard", "-o"],
724
+ ["xsel", "--clipboard", "--output"],
725
+ ]:
726
+ try:
727
+ result = subprocess.run(
728
+ cmd, capture_output=True, text=True, timeout=2
729
+ )
730
+ if result.returncode == 0:
731
+ text = result.stdout
732
+ break
733
+ except FileNotFoundError:
734
+ continue
735
+
736
+ if text:
737
+ # Normalize Windows line endings to Unix style
738
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
739
+ # Strip trailing newline that clipboard tools often add
740
+ text = text.rstrip("\n")
741
+ event.app.current_buffer.insert_text(text)
742
+ except Exception:
743
+ pass # Silently fail if text paste doesn't work
744
+
745
+ # F3 - dedicated image paste (shows error if no image)
746
+ @bindings.add("f3")
747
+ def handle_image_paste_f3(event):
748
+ """Handle F3 - paste image from clipboard (image-only, shows error if none)."""
749
+ try:
750
+ if has_image_in_clipboard():
751
+ placeholder = capture_clipboard_image_to_pending()
752
+ if placeholder:
753
+ event.app.current_buffer.insert_text(placeholder + " ")
754
+ # The placeholder itself is visible feedback
755
+ # Use bell for audible feedback (works in most terminals)
756
+ event.app.output.bell()
757
+ else:
758
+ # Insert a transient message that user can delete
759
+ event.app.current_buffer.insert_text("[⚠️ no image in clipboard] ")
760
+ event.app.output.bell()
761
+ except Exception:
762
+ event.app.current_buffer.insert_text("[❌ clipboard error] ")
763
+ event.app.output.bell()
764
+
647
765
  session = PromptSession(
648
766
  completer=completer,
649
767
  history=history,
code_puppy/config.py CHANGED
@@ -53,6 +53,7 @@ _DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
53
53
  GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
54
54
  CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
55
55
  CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
56
+ ANTIGRAVITY_MODELS_FILE = os.path.join(DATA_DIR, "antigravity_models.json")
56
57
 
57
58
  # Cache files (XDG_CACHE_HOME)
58
59
  AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
@@ -1051,11 +1052,11 @@ def set_enable_dbos(enabled: bool) -> None:
1051
1052
  set_config_value("enable_dbos", "true" if enabled else "false")
1052
1053
 
1053
1054
 
1054
- def get_message_limit(default: int = 100) -> int:
1055
+ def get_message_limit(default: int = 1000) -> int:
1055
1056
  """
1056
1057
  Returns the user-configured message/request limit for the agent.
1057
1058
  This controls how many steps/requests the agent can take.
1058
- Defaults to 100 if unset or misconfigured.
1059
+ Defaults to 1000 if unset or misconfigured.
1059
1060
  Configurable by 'message_limit' key.
1060
1061
  """
1061
1062
  val = get_value("message_limit")
code_puppy/http_utils.py CHANGED
@@ -4,29 +4,81 @@ HTTP utilities module for code-puppy.
4
4
  This module provides functions for creating properly configured HTTP clients.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import os
8
9
  import socket
9
- from typing import Dict, Optional, Union
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, Optional, Union
10
13
 
11
14
  import httpx
12
15
  import requests
13
- from tenacity import stop_after_attempt, wait_exponential
14
16
 
15
17
  from code_puppy.config import get_http2
16
18
 
17
- try:
18
- from pydantic_ai.retries import (
19
- AsyncTenacityTransport,
20
- RetryConfig,
21
- TenacityTransport,
22
- wait_retry_after,
19
+
20
+ @dataclass
21
+ class ProxyConfig:
22
+ """Configuration for proxy and SSL settings."""
23
+
24
+ verify: Union[bool, str, None]
25
+ trust_env: bool
26
+ proxy_url: str | None
27
+ disable_retry: bool
28
+ http2_enabled: bool
29
+
30
+
31
+ def _resolve_proxy_config(verify: Union[bool, str, None] = None) -> ProxyConfig:
32
+ """Resolve proxy, SSL, and retry settings from environment.
33
+
34
+ This centralizes the logic for detecting proxies, determining SSL verification,
35
+ and checking if retry transport should be disabled.
36
+ """
37
+ if verify is None:
38
+ verify = get_cert_bundle_path()
39
+
40
+ http2_enabled = get_http2()
41
+
42
+ disable_retry = os.environ.get(
43
+ "CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
44
+ ).lower() in ("1", "true", "yes")
45
+
46
+ has_proxy = bool(
47
+ os.environ.get("HTTP_PROXY")
48
+ or os.environ.get("HTTPS_PROXY")
49
+ or os.environ.get("http_proxy")
50
+ or os.environ.get("https_proxy")
23
51
  )
24
- except ImportError:
25
- # Fallback if pydantic_ai.retries is not available
26
- AsyncTenacityTransport = None
27
- RetryConfig = None
28
- TenacityTransport = None
29
- wait_retry_after = None
52
+
53
+ # Determine trust_env and verify based on proxy/retry settings
54
+ if disable_retry:
55
+ # Test mode: disable SSL verification for proxy testing
56
+ verify = False
57
+ trust_env = True
58
+ elif has_proxy:
59
+ # Production proxy: keep SSL verification enabled
60
+ trust_env = True
61
+ else:
62
+ trust_env = False
63
+
64
+ # Extract proxy URL
65
+ proxy_url = None
66
+ if has_proxy:
67
+ proxy_url = (
68
+ os.environ.get("HTTPS_PROXY")
69
+ or os.environ.get("https_proxy")
70
+ or os.environ.get("HTTP_PROXY")
71
+ or os.environ.get("http_proxy")
72
+ )
73
+
74
+ return ProxyConfig(
75
+ verify=verify,
76
+ trust_env=trust_env,
77
+ proxy_url=proxy_url,
78
+ disable_retry=disable_retry,
79
+ http2_enabled=http2_enabled,
80
+ )
81
+
30
82
 
31
83
  try:
32
84
  from .reopenable_async_client import ReopenableAsyncClient
@@ -34,14 +86,104 @@ except ImportError:
34
86
  ReopenableAsyncClient = None
35
87
 
36
88
  try:
37
- from .messaging import emit_info
89
+ from .messaging import emit_info, emit_warning
38
90
  except ImportError:
39
91
  # Fallback if messaging system is not available
40
92
  def emit_info(content: str, **metadata):
41
93
  pass # No-op if messaging system is not available
42
94
 
95
+ def emit_warning(content: str, **metadata):
96
+ pass
43
97
 
44
- def get_cert_bundle_path() -> str:
98
+
99
+ class RetryingAsyncClient(httpx.AsyncClient):
100
+ """AsyncClient with built-in rate limit handling (429) and retries.
101
+
102
+ This replaces the Tenacity transport with a more direct subclass implementation,
103
+ which plays nicer with proxies and custom transports (like Antigravity).
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ retry_status_codes: tuple = (429, 502, 503, 504),
109
+ max_retries: int = 5,
110
+ **kwargs,
111
+ ):
112
+ super().__init__(**kwargs)
113
+ self.retry_status_codes = retry_status_codes
114
+ self.max_retries = max_retries
115
+
116
+ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
117
+ """Send request with automatic retries for rate limits and server errors."""
118
+ last_response = None
119
+ last_exception = None
120
+
121
+ for attempt in range(self.max_retries + 1):
122
+ try:
123
+ response = await super().send(request, **kwargs)
124
+ last_response = response
125
+
126
+ # Check for retryable status
127
+ if response.status_code not in self.retry_status_codes:
128
+ return response
129
+
130
+ # Close response if we're going to retry
131
+ await response.aclose()
132
+
133
+ # Determine wait time
134
+ wait_time = 1.0 * (
135
+ 2**attempt
136
+ ) # Default exponential backoff: 1s, 2s, 4s...
137
+
138
+ # Check Retry-After header
139
+ retry_after = response.headers.get("Retry-After")
140
+ if retry_after:
141
+ try:
142
+ wait_time = float(retry_after)
143
+ except ValueError:
144
+ # Try parsing http-date
145
+ from email.utils import parsedate_to_datetime
146
+
147
+ try:
148
+ date = parsedate_to_datetime(retry_after)
149
+ wait_time = date.timestamp() - time.time()
150
+ except Exception:
151
+ pass
152
+
153
+ # Cap wait time
154
+ wait_time = max(0.5, min(wait_time, 60.0))
155
+
156
+ if attempt < self.max_retries:
157
+ emit_info(
158
+ f"HTTP retry: {response.status_code} received. Waiting {wait_time:.1f}s (attempt {attempt + 1}/{self.max_retries})"
159
+ )
160
+ await asyncio.sleep(wait_time)
161
+
162
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout) as e:
163
+ last_exception = e
164
+ wait_time = 1.0 * (2**attempt)
165
+ if attempt < self.max_retries:
166
+ emit_warning(
167
+ f"HTTP connection error: {e}. Retrying in {wait_time}s..."
168
+ )
169
+ await asyncio.sleep(wait_time)
170
+ else:
171
+ raise
172
+ except Exception:
173
+ raise
174
+
175
+ # Return last response (even if it's an error status)
176
+ if last_response:
177
+ return last_response
178
+
179
+ # Should catch this in loop, but just in case
180
+ if last_exception:
181
+ raise last_exception
182
+
183
+ return last_response
184
+
185
+
186
+ def get_cert_bundle_path() -> str | None:
45
187
  # First check if SSL_CERT_FILE environment variable is set
46
188
  ssl_cert_file = os.environ.get("SSL_CERT_FILE")
47
189
  if ssl_cert_file and os.path.exists(ssl_cert_file):
@@ -60,53 +202,15 @@ def create_client(
60
202
  # Check if HTTP/2 is enabled in config
61
203
  http2_enabled = get_http2()
62
204
 
63
- # Check if custom retry transport should be disabled (e.g., for integration tests with proxies)
64
- disable_retry_transport = os.environ.get(
65
- "CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
66
- ).lower() in ("1", "true", "yes")
67
-
68
205
  # If retry components are available, create a client with retry transport
69
- if (
70
- TenacityTransport
71
- and RetryConfig
72
- and wait_retry_after
73
- and not disable_retry_transport
74
- ):
75
-
76
- def should_retry_status(response):
77
- """Raise exceptions for retryable HTTP status codes."""
78
- if response.status_code in retry_status_codes:
79
- emit_info(
80
- f"HTTP retry: Retrying request due to status code {response.status_code}"
81
- )
82
- return True
83
-
84
- transport = TenacityTransport(
85
- config=RetryConfig(
86
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
87
- and e.response.status_code in retry_status_codes,
88
- wait=wait_retry_after(
89
- fallback_strategy=wait_exponential(multiplier=1, max=60),
90
- max_wait=300,
91
- ),
92
- stop=stop_after_attempt(10),
93
- reraise=True,
94
- ),
95
- validate_response=should_retry_status,
96
- )
97
-
98
- return httpx.Client(
99
- transport=transport,
100
- verify=verify,
101
- headers=headers or {},
102
- timeout=timeout,
103
- http2=http2_enabled,
104
- )
105
- else:
106
- # Fallback to regular client if retry components are not available
107
- return httpx.Client(
108
- verify=verify, headers=headers or {}, timeout=timeout, http2=http2_enabled
109
- )
206
+ # Note: TenacityTransport was removed. For now we just return a standard client.
207
+ # Future TODO: Implement RetryingClient(httpx.Client) if needed.
208
+ return httpx.Client(
209
+ verify=verify,
210
+ headers=headers or {},
211
+ timeout=timeout,
212
+ http2=http2_enabled,
213
+ )
110
214
 
111
215
 
112
216
  def create_async_client(
@@ -115,109 +219,26 @@ def create_async_client(
115
219
  headers: Optional[Dict[str, str]] = None,
116
220
  retry_status_codes: tuple = (429, 502, 503, 504),
117
221
  ) -> httpx.AsyncClient:
118
- if verify is None:
119
- verify = get_cert_bundle_path()
120
-
121
- # Check if HTTP/2 is enabled in config
122
- http2_enabled = get_http2()
222
+ config = _resolve_proxy_config(verify)
123
223
 
124
- # Check if custom retry transport should be disabled (e.g., for integration tests with proxies)
125
- disable_retry_transport = os.environ.get(
126
- "CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
127
- ).lower() in ("1", "true", "yes")
128
-
129
- # Check if proxy environment variables are set
130
- has_proxy = bool(
131
- os.environ.get("HTTP_PROXY")
132
- or os.environ.get("HTTPS_PROXY")
133
- or os.environ.get("http_proxy")
134
- or os.environ.get("https_proxy")
135
- )
136
-
137
- # When retry transport is disabled (test mode), disable SSL verification
138
- # for proxy testing. For production proxies, SSL should still be verified!
139
- if disable_retry_transport:
140
- verify = False
141
- trust_env = True
142
- elif has_proxy:
143
- # Production proxy detected - keep SSL verification enabled for security
144
- trust_env = True
145
- else:
146
- trust_env = False
147
-
148
- # If retry components are available, create a client with retry transport
149
- # BUT: disable retry transport when proxies are detected because custom transports
150
- # don't play nicely with proxy configuration
151
- if (
152
- AsyncTenacityTransport
153
- and RetryConfig
154
- and wait_retry_after
155
- and not disable_retry_transport
156
- and not has_proxy
157
- ):
158
-
159
- def should_retry_status(response):
160
- """Raise exceptions for retryable HTTP status codes."""
161
- if response.status_code in retry_status_codes:
162
- emit_info(
163
- f"HTTP retry: Retrying request due to status code {response.status_code}"
164
- )
165
- return True
166
-
167
- # Create transport (with or without proxy base)
168
- if has_proxy:
169
- # Extract proxy URL from environment
170
- proxy_url = (
171
- os.environ.get("HTTPS_PROXY")
172
- or os.environ.get("https_proxy")
173
- or os.environ.get("HTTP_PROXY")
174
- or os.environ.get("http_proxy")
175
- )
176
- else:
177
- proxy_url = None
178
-
179
- # Create retry transport wrapper
180
- transport = AsyncTenacityTransport(
181
- config=RetryConfig(
182
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
183
- and e.response.status_code in retry_status_codes,
184
- wait=wait_retry_after(10),
185
- stop=stop_after_attempt(10),
186
- reraise=True,
187
- ),
188
- validate_response=should_retry_status,
189
- )
190
-
191
- return httpx.AsyncClient(
192
- transport=transport,
193
- proxy=proxy_url, # Pass proxy to client, not transport
194
- verify=verify,
224
+ if not config.disable_retry:
225
+ return RetryingAsyncClient(
226
+ retry_status_codes=retry_status_codes,
227
+ proxy=config.proxy_url,
228
+ verify=config.verify,
195
229
  headers=headers or {},
196
230
  timeout=timeout,
197
- http2=http2_enabled,
198
- trust_env=trust_env,
231
+ http2=config.http2_enabled,
232
+ trust_env=config.trust_env,
199
233
  )
200
234
  else:
201
- # Fallback to regular client if retry components are not available,
202
- # when retry transport is explicitly disabled, or when proxies are detected
203
- # Extract proxy URL if needed
204
- if has_proxy:
205
- proxy_url = (
206
- os.environ.get("HTTPS_PROXY")
207
- or os.environ.get("https_proxy")
208
- or os.environ.get("HTTP_PROXY")
209
- or os.environ.get("http_proxy")
210
- )
211
- else:
212
- proxy_url = None
213
-
214
235
  return httpx.AsyncClient(
215
- proxy=proxy_url,
216
- verify=verify,
236
+ proxy=config.proxy_url,
237
+ verify=config.verify,
217
238
  headers=headers or {},
218
239
  timeout=timeout,
219
- http2=http2_enabled,
220
- trust_env=trust_env,
240
+ http2=config.http2_enabled,
241
+ trust_env=config.trust_env,
221
242
  )
222
243
 
223
244
 
@@ -267,132 +288,33 @@ def create_reopenable_async_client(
267
288
  headers: Optional[Dict[str, str]] = None,
268
289
  retry_status_codes: tuple = (429, 502, 503, 504),
269
290
  ) -> Union[ReopenableAsyncClient, httpx.AsyncClient]:
270
- if verify is None:
271
- verify = get_cert_bundle_path()
272
-
273
- # Check if HTTP/2 is enabled in config
274
- http2_enabled = get_http2()
275
-
276
- # Check if custom retry transport should be disabled (e.g., for integration tests with proxies)
277
- disable_retry_transport = os.environ.get(
278
- "CODE_PUPPY_DISABLE_RETRY_TRANSPORT", ""
279
- ).lower() in ("1", "true", "yes")
280
-
281
- # Check if proxy environment variables are set
282
- has_proxy = bool(
283
- os.environ.get("HTTP_PROXY")
284
- or os.environ.get("HTTPS_PROXY")
285
- or os.environ.get("http_proxy")
286
- or os.environ.get("https_proxy")
287
- )
288
-
289
- # When retry transport is disabled (test mode), disable SSL verification
290
- if disable_retry_transport:
291
- verify = False
292
- trust_env = True
293
- elif has_proxy:
294
- trust_env = True
295
- else:
296
- trust_env = False
297
-
298
- # If retry components are available, create a client with retry transport
299
- # BUT: disable retry transport when proxies are detected because custom transports
300
- # don't play nicely with proxy configuration
301
- if (
302
- AsyncTenacityTransport
303
- and RetryConfig
304
- and wait_retry_after
305
- and not disable_retry_transport
306
- and not has_proxy
307
- ):
308
-
309
- def should_retry_status(response):
310
- """Raise exceptions for retryable HTTP status codes."""
311
- if response.status_code in retry_status_codes:
312
- emit_info(
313
- f"HTTP retry: Retrying request due to status code {response.status_code}"
314
- )
315
- return True
316
-
317
- transport = AsyncTenacityTransport(
318
- config=RetryConfig(
319
- retry=lambda e: isinstance(e, httpx.HTTPStatusError)
320
- and e.response.status_code in retry_status_codes,
321
- wait=wait_retry_after(
322
- fallback_strategy=wait_exponential(multiplier=1, max=60),
323
- max_wait=300,
324
- ),
325
- stop=stop_after_attempt(10),
326
- reraise=True,
327
- ),
328
- validate_response=should_retry_status,
291
+ config = _resolve_proxy_config(verify)
292
+
293
+ base_kwargs = {
294
+ "proxy": config.proxy_url,
295
+ "verify": config.verify,
296
+ "headers": headers or {},
297
+ "timeout": timeout,
298
+ "http2": config.http2_enabled,
299
+ "trust_env": config.trust_env,
300
+ }
301
+
302
+ if ReopenableAsyncClient is not None:
303
+ client_class = (
304
+ RetryingAsyncClient if not config.disable_retry else httpx.AsyncClient
329
305
  )
330
-
331
- # Extract proxy URL if needed
332
- if has_proxy:
333
- proxy_url = (
334
- os.environ.get("HTTPS_PROXY")
335
- or os.environ.get("https_proxy")
336
- or os.environ.get("HTTP_PROXY")
337
- or os.environ.get("http_proxy")
338
- )
339
- else:
340
- proxy_url = None
341
-
342
- if ReopenableAsyncClient is not None:
343
- return ReopenableAsyncClient(
344
- transport=transport,
345
- proxy=proxy_url,
346
- verify=verify,
347
- headers=headers or {},
348
- timeout=timeout,
349
- http2=http2_enabled,
350
- trust_env=trust_env,
351
- )
352
- else:
353
- # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
354
- return httpx.AsyncClient(
355
- transport=transport,
356
- proxy=proxy_url,
357
- verify=verify,
358
- headers=headers or {},
359
- timeout=timeout,
360
- http2=http2_enabled,
361
- trust_env=trust_env,
362
- )
306
+ kwargs = {**base_kwargs, "client_class": client_class}
307
+ if not config.disable_retry:
308
+ kwargs["retry_status_codes"] = retry_status_codes
309
+ return ReopenableAsyncClient(**kwargs)
363
310
  else:
364
- # Fallback to regular clients if retry components are not available
365
- # or when proxies are detected
366
- # Extract proxy URL if needed
367
- if has_proxy:
368
- proxy_url = (
369
- os.environ.get("HTTPS_PROXY")
370
- or os.environ.get("https_proxy")
371
- or os.environ.get("HTTP_PROXY")
372
- or os.environ.get("http_proxy")
311
+ # Fallback to RetryingAsyncClient or plain AsyncClient
312
+ if not config.disable_retry:
313
+ return RetryingAsyncClient(
314
+ retry_status_codes=retry_status_codes, **base_kwargs
373
315
  )
374
316
  else:
375
- proxy_url = None
376
-
377
- if ReopenableAsyncClient is not None:
378
- return ReopenableAsyncClient(
379
- proxy=proxy_url,
380
- verify=verify,
381
- headers=headers or {},
382
- timeout=timeout,
383
- http2=http2_enabled,
384
- trust_env=trust_env,
385
- )
386
- else:
387
- # Fallback to regular AsyncClient if ReopenableAsyncClient is not available
388
- return httpx.AsyncClient(
389
- proxy=proxy_url,
390
- verify=verify,
391
- headers=headers or {},
392
- timeout=timeout,
393
- http2=http2_enabled,
394
- trust_env=trust_env,
395
- )
317
+ return httpx.AsyncClient(**base_kwargs)
396
318
 
397
319
 
398
320
  def is_cert_bundle_available() -> bool: