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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|