ccproxy-api 0.1.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 (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,97 @@
1
+ """Core constants used across the CCProxy API."""
2
+
3
+ # HTTP headers
4
+ REQUEST_ID_HEADER = "X-Request-ID"
5
+ AUTH_HEADER = "Authorization"
6
+ CONTENT_TYPE_HEADER = "Content-Type"
7
+
8
+ # API endpoints
9
+ ANTHROPIC_API_BASE_PATH = "/v1"
10
+ OPENAI_API_BASE_PATH = "/openai/v1"
11
+ CHAT_COMPLETIONS_ENDPOINT = "/chat/completions"
12
+ MESSAGES_ENDPOINT = "/messages"
13
+ MODELS_ENDPOINT = "/models"
14
+
15
+ # Default values
16
+ DEFAULT_MODEL = "claude-3-5-sonnet-20241022"
17
+ DEFAULT_MAX_TOKENS = 4096
18
+ DEFAULT_TEMPERATURE = 1.0
19
+ DEFAULT_TOP_P = 1.0
20
+ DEFAULT_STREAM = False
21
+
22
+ # Timeouts (in seconds)
23
+ DEFAULT_TIMEOUT = 30
24
+ DEFAULT_CONNECT_TIMEOUT = 10
25
+ DEFAULT_READ_TIMEOUT = 300
26
+
27
+ # Rate limiting
28
+ DEFAULT_RATE_LIMIT = 100 # requests per minute
29
+ DEFAULT_BURST_LIMIT = 10 # burst capacity
30
+
31
+ # Docker defaults
32
+ DEFAULT_DOCKER_IMAGE = "ghcr.io/anthropics/claude-cli:latest"
33
+ DEFAULT_DOCKER_TIMEOUT = 300
34
+
35
+ # File extensions
36
+ TOML_EXTENSIONS = [".toml"]
37
+ JSON_EXTENSIONS = [".json"]
38
+ YAML_EXTENSIONS = [".yaml", ".yml"]
39
+
40
+ # Configuration file names
41
+ CONFIG_FILE_NAMES = [
42
+ ".ccproxy.toml",
43
+ "ccproxy.toml",
44
+ "config.toml",
45
+ "config.json",
46
+ "config.yaml",
47
+ "config.yml",
48
+ ]
49
+
50
+ # Environment variable prefixes
51
+ ENV_PREFIX = "CCPROXY_"
52
+ CLAUDE_ENV_PREFIX = "CLAUDE_"
53
+
54
+ # Logging levels
55
+ LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
56
+
57
+ # Error messages
58
+ ERROR_MSG_INVALID_TOKEN = "Invalid or expired authentication token"
59
+ ERROR_MSG_MODEL_NOT_FOUND = "Model not found or not available"
60
+ ERROR_MSG_RATE_LIMIT_EXCEEDED = "Rate limit exceeded"
61
+ ERROR_MSG_INVALID_REQUEST = "Invalid request format"
62
+ ERROR_MSG_INTERNAL_ERROR = "Internal server error"
63
+
64
+ # Status codes
65
+ STATUS_OK = 200
66
+ STATUS_CREATED = 201
67
+ STATUS_BAD_REQUEST = 400
68
+ STATUS_UNAUTHORIZED = 401
69
+ STATUS_FORBIDDEN = 403
70
+ STATUS_NOT_FOUND = 404
71
+ STATUS_RATE_LIMITED = 429
72
+ STATUS_INTERNAL_ERROR = 500
73
+ STATUS_BAD_GATEWAY = 502
74
+ STATUS_SERVICE_UNAVAILABLE = 503
75
+
76
+ # Stream event types
77
+ STREAM_EVENT_MESSAGE_START = "message_start"
78
+ STREAM_EVENT_MESSAGE_DELTA = "message_delta"
79
+ STREAM_EVENT_MESSAGE_STOP = "message_stop"
80
+ STREAM_EVENT_CONTENT_BLOCK_START = "content_block_start"
81
+ STREAM_EVENT_CONTENT_BLOCK_DELTA = "content_block_delta"
82
+ STREAM_EVENT_CONTENT_BLOCK_STOP = "content_block_stop"
83
+
84
+ # Content types
85
+ CONTENT_TYPE_JSON = "application/json"
86
+ CONTENT_TYPE_STREAM = "text/event-stream"
87
+ CONTENT_TYPE_TEXT = "text/plain"
88
+
89
+ # Character limits
90
+ MAX_PROMPT_LENGTH = 200_000 # Maximum prompt length in characters
91
+ MAX_MESSAGE_LENGTH = 100_000 # Maximum message length
92
+ MAX_TOOL_CALLS = 100 # Maximum number of tool calls per request
93
+
94
+ # Validation patterns
95
+ EMAIL_PATTERN = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
96
+ URL_PATTERN = r"^https?://[^\s/$.?#].[^\s]*$"
97
+ UUID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
ccproxy/core/errors.py ADDED
@@ -0,0 +1,256 @@
1
+ """Core error types for the proxy system."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from fastapi import HTTPException
6
+
7
+
8
+ class ProxyHTTPException(HTTPException):
9
+ pass
10
+
11
+
12
+ class ProxyError(Exception):
13
+ """Base exception for all proxy-related errors."""
14
+
15
+ def __init__(self, message: str, cause: Exception | None = None):
16
+ """Initialize with a message and optional cause.
17
+
18
+ Args:
19
+ message: The error message
20
+ cause: The underlying exception that caused this error
21
+ """
22
+ super().__init__(message)
23
+ self.cause = cause
24
+ if cause:
25
+ # Use Python's exception chaining
26
+ self.__cause__ = cause
27
+
28
+
29
+ class TransformationError(ProxyError):
30
+ """Error raised during data transformation."""
31
+
32
+ def __init__(self, message: str, data: Any = None, cause: Exception | None = None):
33
+ """Initialize with a message, optional data, and cause.
34
+
35
+ Args:
36
+ message: The error message
37
+ data: The data that failed to transform
38
+ cause: The underlying exception
39
+ """
40
+ super().__init__(message, cause)
41
+ self.data = data
42
+
43
+
44
+ class MiddlewareError(ProxyError):
45
+ """Error raised during middleware execution."""
46
+
47
+ def __init__(
48
+ self,
49
+ message: str,
50
+ middleware_name: str | None = None,
51
+ cause: Exception | None = None,
52
+ ):
53
+ """Initialize with a message, middleware name, and cause.
54
+
55
+ Args:
56
+ message: The error message
57
+ middleware_name: The name of the middleware that failed
58
+ cause: The underlying exception
59
+ """
60
+ super().__init__(message, cause)
61
+ self.middleware_name = middleware_name
62
+
63
+
64
+ class ProxyConnectionError(ProxyError):
65
+ """Error raised when proxy connection fails."""
66
+
67
+ def __init__(
68
+ self, message: str, url: str | None = None, cause: Exception | None = None
69
+ ):
70
+ """Initialize with a message, URL, and cause.
71
+
72
+ Args:
73
+ message: The error message
74
+ url: The URL that failed to connect
75
+ cause: The underlying exception
76
+ """
77
+ super().__init__(message, cause)
78
+ self.url = url
79
+
80
+
81
+ class ProxyTimeoutError(ProxyError):
82
+ """Error raised when proxy operation times out."""
83
+
84
+ def __init__(
85
+ self,
86
+ message: str,
87
+ timeout: float | None = None,
88
+ cause: Exception | None = None,
89
+ ):
90
+ """Initialize with a message, timeout value, and cause.
91
+
92
+ Args:
93
+ message: The error message
94
+ timeout: The timeout value in seconds
95
+ cause: The underlying exception
96
+ """
97
+ super().__init__(message, cause)
98
+ self.timeout = timeout
99
+
100
+
101
+ class ProxyAuthenticationError(ProxyError):
102
+ """Error raised when proxy authentication fails."""
103
+
104
+ def __init__(
105
+ self,
106
+ message: str,
107
+ auth_type: str | None = None,
108
+ cause: Exception | None = None,
109
+ ):
110
+ """Initialize with a message, auth type, and cause.
111
+
112
+ Args:
113
+ message: The error message
114
+ auth_type: The type of authentication that failed
115
+ cause: The underlying exception
116
+ """
117
+ super().__init__(message, cause)
118
+ self.auth_type = auth_type
119
+
120
+
121
+ # API-level exceptions (consolidated from exceptions.py)
122
+ class ClaudeProxyError(Exception):
123
+ """Base exception for Claude Proxy errors."""
124
+
125
+ def __init__(
126
+ self,
127
+ message: str,
128
+ error_type: str = "internal_server_error",
129
+ status_code: int = 500,
130
+ details: dict[str, Any] | None = None,
131
+ ) -> None:
132
+ super().__init__(message)
133
+ self.message = message
134
+ self.error_type = error_type
135
+ self.status_code = status_code
136
+ self.details = details or {}
137
+
138
+
139
+ class ValidationError(ClaudeProxyError):
140
+ """Validation error (400)."""
141
+
142
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
143
+ super().__init__(
144
+ message=message,
145
+ error_type="invalid_request_error",
146
+ status_code=400,
147
+ details=details,
148
+ )
149
+
150
+
151
+ class AuthenticationError(ClaudeProxyError):
152
+ """Authentication error (401)."""
153
+
154
+ def __init__(self, message: str = "Authentication failed") -> None:
155
+ super().__init__(
156
+ message=message, error_type="authentication_error", status_code=401
157
+ )
158
+
159
+
160
+ class PermissionError(ClaudeProxyError):
161
+ """Permission error (403)."""
162
+
163
+ def __init__(self, message: str = "Permission denied") -> None:
164
+ super().__init__(
165
+ message=message, error_type="permission_error", status_code=403
166
+ )
167
+
168
+
169
+ class NotFoundError(ClaudeProxyError):
170
+ """Not found error (404)."""
171
+
172
+ def __init__(self, message: str = "Resource not found") -> None:
173
+ super().__init__(message=message, error_type="not_found_error", status_code=404)
174
+
175
+
176
+ class RateLimitError(ClaudeProxyError):
177
+ """Rate limit error (429)."""
178
+
179
+ def __init__(self, message: str = "Rate limit exceeded") -> None:
180
+ super().__init__(
181
+ message=message, error_type="rate_limit_error", status_code=429
182
+ )
183
+
184
+
185
+ class ModelNotFoundError(ClaudeProxyError):
186
+ """Model not found error (404)."""
187
+
188
+ def __init__(self, model: str) -> None:
189
+ super().__init__(
190
+ message=f"Model '{model}' not found",
191
+ error_type="not_found_error",
192
+ status_code=404,
193
+ )
194
+
195
+
196
+ class TimeoutError(ClaudeProxyError):
197
+ """Request timeout error (408)."""
198
+
199
+ def __init__(self, message: str = "Request timeout") -> None:
200
+ super().__init__(message=message, error_type="timeout_error", status_code=408)
201
+
202
+
203
+ class ServiceUnavailableError(ClaudeProxyError):
204
+ """Service unavailable error (503)."""
205
+
206
+ def __init__(self, message: str = "Service temporarily unavailable") -> None:
207
+ super().__init__(
208
+ message=message, error_type="service_unavailable_error", status_code=503
209
+ )
210
+
211
+
212
+ class DockerError(ClaudeProxyError):
213
+ """Docker operation error."""
214
+
215
+ def __init__(
216
+ self,
217
+ message: str,
218
+ command: str | None = None,
219
+ cause: Exception | None = None,
220
+ details: dict[str, Any] | None = None,
221
+ ) -> None:
222
+ error_details = details or {}
223
+ if command:
224
+ error_details["command"] = command
225
+ if cause:
226
+ error_details["cause"] = str(cause)
227
+ error_details["cause_type"] = type(cause).__name__
228
+
229
+ super().__init__(
230
+ message=message,
231
+ error_type="docker_error",
232
+ status_code=500,
233
+ details=error_details,
234
+ )
235
+
236
+
237
+ __all__ = [
238
+ # Core proxy errors
239
+ "ProxyError",
240
+ "TransformationError",
241
+ "MiddlewareError",
242
+ "ProxyConnectionError",
243
+ "ProxyTimeoutError",
244
+ "ProxyAuthenticationError",
245
+ # API-level errors
246
+ "ClaudeProxyError",
247
+ "ValidationError",
248
+ "AuthenticationError",
249
+ "PermissionError",
250
+ "NotFoundError",
251
+ "RateLimitError",
252
+ "ModelNotFoundError",
253
+ "TimeoutError",
254
+ "ServiceUnavailableError",
255
+ "DockerError",
256
+ ]
ccproxy/core/http.py ADDED
@@ -0,0 +1,328 @@
1
+ """Generic HTTP client abstractions for pure forwarding without business logic."""
2
+
3
+ import os
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import structlog
9
+
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ import httpx
16
+
17
+
18
+ class HTTPClient(ABC):
19
+ """Abstract HTTP client interface for generic HTTP operations."""
20
+
21
+ @abstractmethod
22
+ async def request(
23
+ self,
24
+ method: str,
25
+ url: str,
26
+ headers: dict[str, str],
27
+ body: bytes | None = None,
28
+ timeout: float | None = None,
29
+ ) -> tuple[int, dict[str, str], bytes]:
30
+ """Make an HTTP request.
31
+
32
+ Args:
33
+ method: HTTP method (GET, POST, etc.)
34
+ url: Target URL
35
+ headers: HTTP headers
36
+ body: Request body (optional)
37
+ timeout: Request timeout in seconds (optional)
38
+
39
+ Returns:
40
+ Tuple of (status_code, response_headers, response_body)
41
+
42
+ Raises:
43
+ HTTPError: If the request fails
44
+ """
45
+ pass
46
+
47
+ @abstractmethod
48
+ async def close(self) -> None:
49
+ """Close any resources held by the HTTP client."""
50
+ pass
51
+
52
+
53
+ class BaseProxyClient:
54
+ """Generic proxy client with no business logic - pure forwarding."""
55
+
56
+ def __init__(self, http_client: HTTPClient) -> None:
57
+ """Initialize with an HTTP client.
58
+
59
+ Args:
60
+ http_client: The HTTP client to use for requests
61
+ """
62
+ self.http_client = http_client
63
+
64
+ async def forward(
65
+ self,
66
+ method: str,
67
+ url: str,
68
+ headers: dict[str, str],
69
+ body: bytes | None = None,
70
+ timeout: float | None = None,
71
+ ) -> tuple[int, dict[str, str], bytes]:
72
+ """Forward an HTTP request without any transformations.
73
+
74
+ Args:
75
+ method: HTTP method
76
+ url: Target URL
77
+ headers: HTTP headers
78
+ body: Request body (optional)
79
+ timeout: Request timeout in seconds (optional)
80
+
81
+ Returns:
82
+ Tuple of (status_code, response_headers, response_body)
83
+
84
+ Raises:
85
+ HTTPError: If the request fails
86
+ """
87
+ return await self.http_client.request(method, url, headers, body, timeout)
88
+
89
+ async def close(self) -> None:
90
+ """Close any resources held by the proxy client."""
91
+ await self.http_client.close()
92
+
93
+
94
+ class HTTPError(Exception):
95
+ """Base exception for HTTP client errors."""
96
+
97
+ def __init__(self, message: str, status_code: int | None = None) -> None:
98
+ """Initialize HTTP error.
99
+
100
+ Args:
101
+ message: Error message
102
+ status_code: HTTP status code (optional)
103
+ """
104
+ super().__init__(message)
105
+ self.status_code = status_code
106
+
107
+
108
+ class HTTPTimeoutError(HTTPError):
109
+ """Exception raised when HTTP request times out."""
110
+
111
+ def __init__(self, message: str = "Request timed out") -> None:
112
+ """Initialize timeout error.
113
+
114
+ Args:
115
+ message: Error message
116
+ """
117
+ super().__init__(message, status_code=408)
118
+
119
+
120
+ class HTTPConnectionError(HTTPError):
121
+ """Exception raised when HTTP connection fails."""
122
+
123
+ def __init__(self, message: str = "Connection failed") -> None:
124
+ """Initialize connection error.
125
+
126
+ Args:
127
+ message: Error message
128
+ """
129
+ super().__init__(message, status_code=503)
130
+
131
+
132
+ class HTTPXClient(HTTPClient):
133
+ """HTTPX-based HTTP client implementation."""
134
+
135
+ def __init__(
136
+ self,
137
+ timeout: float = 240.0,
138
+ proxy: str | None = None,
139
+ verify: bool | str = True,
140
+ ) -> None:
141
+ """Initialize HTTPX client.
142
+
143
+ Args:
144
+ timeout: Request timeout in seconds
145
+ proxy: HTTP proxy URL (optional)
146
+ verify: SSL verification (True/False or path to CA bundle)
147
+ """
148
+ import httpx
149
+
150
+ self.timeout = timeout
151
+ self.proxy = proxy
152
+ self.verify = verify
153
+ self._client: httpx.AsyncClient | None = None
154
+
155
+ async def _get_client(self) -> "httpx.AsyncClient":
156
+ """Get or create the HTTPX client."""
157
+ if self._client is None:
158
+ import httpx
159
+
160
+ self._client = httpx.AsyncClient(
161
+ timeout=self.timeout,
162
+ proxy=self.proxy,
163
+ verify=self.verify,
164
+ )
165
+ return self._client
166
+
167
+ async def request(
168
+ self,
169
+ method: str,
170
+ url: str,
171
+ headers: dict[str, str],
172
+ body: bytes | None = None,
173
+ timeout: float | None = None,
174
+ ) -> tuple[int, dict[str, str], bytes]:
175
+ """Make an HTTP request using HTTPX.
176
+
177
+ Args:
178
+ method: HTTP method
179
+ url: Target URL
180
+ headers: HTTP headers
181
+ body: Request body (optional)
182
+ timeout: Request timeout in seconds (optional)
183
+
184
+ Returns:
185
+ Tuple of (status_code, response_headers, response_body)
186
+
187
+ Raises:
188
+ HTTPError: If the request fails
189
+ """
190
+ import httpx
191
+
192
+ try:
193
+ client = await self._get_client()
194
+
195
+ # Use provided timeout if available
196
+ if timeout is not None:
197
+ # Create a new client with different timeout if needed
198
+ import httpx
199
+
200
+ client = httpx.AsyncClient(
201
+ timeout=timeout,
202
+ proxy=self.proxy,
203
+ verify=self.verify,
204
+ )
205
+
206
+ response = await client.request(
207
+ method=method,
208
+ url=url,
209
+ headers=headers,
210
+ content=body,
211
+ )
212
+
213
+ # Always return the response, even for error status codes
214
+ # This allows the proxy to forward upstream errors directly
215
+ return (
216
+ response.status_code,
217
+ dict(response.headers),
218
+ response.content,
219
+ )
220
+
221
+ except httpx.TimeoutException as e:
222
+ raise HTTPTimeoutError(f"Request timed out: {e}") from e
223
+ except httpx.ConnectError as e:
224
+ raise HTTPConnectionError(f"Connection failed: {e}") from e
225
+ except httpx.HTTPStatusError as e:
226
+ # This shouldn't happen with the default raise_for_status=False
227
+ # but keep it just in case
228
+ raise HTTPError(
229
+ f"HTTP {e.response.status_code}: {e.response.reason_phrase}",
230
+ status_code=e.response.status_code,
231
+ ) from e
232
+ except Exception as e:
233
+ raise HTTPError(f"HTTP request failed: {e}") from e
234
+
235
+ async def stream(
236
+ self,
237
+ method: str,
238
+ url: str,
239
+ headers: dict[str, str],
240
+ content: bytes | None = None,
241
+ ) -> Any:
242
+ """Create a streaming HTTP request.
243
+
244
+ Args:
245
+ method: HTTP method
246
+ url: Target URL
247
+ headers: HTTP headers
248
+ content: Request body (optional)
249
+
250
+ Returns:
251
+ HTTPX streaming response context manager
252
+ """
253
+ client = await self._get_client()
254
+ return client.stream(
255
+ method=method,
256
+ url=url,
257
+ headers=headers,
258
+ content=content,
259
+ )
260
+
261
+ async def close(self) -> None:
262
+ """Close the HTTPX client."""
263
+ if self._client is not None:
264
+ await self._client.aclose()
265
+ self._client = None
266
+
267
+
268
+ def get_proxy_url() -> str | None:
269
+ """Get proxy URL from environment variables.
270
+
271
+ Returns:
272
+ str or None: Proxy URL if any proxy is set
273
+ """
274
+ # Check for standard proxy environment variables
275
+ # For HTTPS requests, prioritize HTTPS_PROXY
276
+ https_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
277
+ all_proxy = os.environ.get("ALL_PROXY")
278
+ http_proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
279
+
280
+ proxy_url = https_proxy or all_proxy or http_proxy
281
+
282
+ if proxy_url:
283
+ logger.debug(
284
+ "proxy_configured",
285
+ proxy_url=proxy_url,
286
+ operation="get_proxy_url",
287
+ )
288
+
289
+ return proxy_url
290
+
291
+
292
+ def get_ssl_context() -> str | bool:
293
+ """Get SSL context configuration from environment variables.
294
+
295
+ Returns:
296
+ SSL verification configuration:
297
+ - Path to CA bundle file
298
+ - True for default verification
299
+ - False to disable verification (insecure)
300
+ """
301
+ # Check for custom CA bundle
302
+ ca_bundle = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE")
303
+
304
+ # Check if SSL verification should be disabled (NOT RECOMMENDED)
305
+ ssl_verify = os.environ.get("SSL_VERIFY", "true").lower()
306
+
307
+ if ca_bundle and Path(ca_bundle).exists():
308
+ logger.info(
309
+ "ssl_ca_bundle_configured",
310
+ ca_bundle_path=ca_bundle,
311
+ operation="get_ssl_context",
312
+ )
313
+ return ca_bundle
314
+ elif ssl_verify in ("false", "0", "no"):
315
+ logger.warning(
316
+ "ssl_verification_disabled",
317
+ ssl_verify_value=ssl_verify,
318
+ operation="get_ssl_context",
319
+ security_warning=True,
320
+ )
321
+ return False
322
+ else:
323
+ logger.debug(
324
+ "ssl_default_verification",
325
+ ssl_verify_value=ssl_verify,
326
+ operation="get_ssl_context",
327
+ )
328
+ return True