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
ccproxy/core/logging.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import MutableMapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from structlog.stdlib import BoundLogger
|
|
8
|
+
from structlog.typing import Processor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_structlog(json_logs: bool = False, log_level: str = "INFO") -> None:
|
|
12
|
+
"""Configure structlog with your preferred processors."""
|
|
13
|
+
# Use different timestamp format based on log level
|
|
14
|
+
# Dev mode (DEBUG): only hours without microseconds
|
|
15
|
+
# Info mode: full date without microseconds
|
|
16
|
+
if log_level.upper() == "DEBUG":
|
|
17
|
+
timestamper = structlog.processors.TimeStamper(fmt="%H:%M:%S")
|
|
18
|
+
else:
|
|
19
|
+
timestamper = structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S")
|
|
20
|
+
|
|
21
|
+
# Processors that will be used for structlog loggers
|
|
22
|
+
processors: list[Processor] = [
|
|
23
|
+
structlog.stdlib.filter_by_level,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Only add logger name if NOT in INFO mode
|
|
27
|
+
if log_level.upper() != "INFO":
|
|
28
|
+
processors.append(structlog.stdlib.add_logger_name)
|
|
29
|
+
|
|
30
|
+
processors.extend(
|
|
31
|
+
[
|
|
32
|
+
structlog.stdlib.add_log_level,
|
|
33
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
34
|
+
timestamper,
|
|
35
|
+
structlog.processors.StackInfoRenderer(),
|
|
36
|
+
structlog.processors.format_exc_info,
|
|
37
|
+
structlog.processors.UnicodeDecoder(),
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Only add CallsiteParameterAdder if NOT in INFO mode
|
|
42
|
+
if log_level.upper() != "INFO":
|
|
43
|
+
processors.append(
|
|
44
|
+
structlog.processors.CallsiteParameterAdder(
|
|
45
|
+
parameters=[
|
|
46
|
+
structlog.processors.CallsiteParameter.FILENAME,
|
|
47
|
+
structlog.processors.CallsiteParameter.LINENO,
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# This wrapper passes the event dictionary to the ProcessorFormatter
|
|
53
|
+
# so we don't double-render
|
|
54
|
+
processors.append(structlog.stdlib.ProcessorFormatter.wrap_for_formatter)
|
|
55
|
+
|
|
56
|
+
structlog.configure(
|
|
57
|
+
processors=processors,
|
|
58
|
+
context_class=dict,
|
|
59
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
60
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
61
|
+
cache_logger_on_first_use=False, # Don't cache to allow reconfiguration
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def setup_logging(
|
|
66
|
+
json_logs: bool = False, log_level: str = "INFO", log_file: str | None = None
|
|
67
|
+
) -> BoundLogger:
|
|
68
|
+
"""
|
|
69
|
+
Setup logging for the entire application including uvicorn and fastapi.
|
|
70
|
+
Returns a structlog logger instance.
|
|
71
|
+
"""
|
|
72
|
+
# Set the log level for the root logger first so structlog can see it
|
|
73
|
+
root_logger = logging.getLogger()
|
|
74
|
+
root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
|
75
|
+
|
|
76
|
+
# Configure structlog after setting the log level
|
|
77
|
+
configure_structlog(json_logs=json_logs, log_level=log_level)
|
|
78
|
+
|
|
79
|
+
# Create a handler that will format stdlib logs through structlog
|
|
80
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
81
|
+
handler.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
|
82
|
+
|
|
83
|
+
# Use the appropriate renderer based on json_logs setting
|
|
84
|
+
renderer = (
|
|
85
|
+
structlog.processors.JSONRenderer()
|
|
86
|
+
if json_logs
|
|
87
|
+
else structlog.dev.ConsoleRenderer()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Use ProcessorFormatter to handle both structlog and stdlib logs
|
|
91
|
+
# Use the same timestamp format for foreign logs
|
|
92
|
+
if log_level.upper() == "DEBUG":
|
|
93
|
+
foreign_timestamper = structlog.processors.TimeStamper(fmt="%H:%M:%S")
|
|
94
|
+
else:
|
|
95
|
+
foreign_timestamper = structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S")
|
|
96
|
+
|
|
97
|
+
# Build foreign_pre_chain conditionally
|
|
98
|
+
foreign_pre_chain: list[Processor] = [structlog.stdlib.add_log_level]
|
|
99
|
+
|
|
100
|
+
# Only add logger name if NOT in INFO mode
|
|
101
|
+
if log_level.upper() != "INFO":
|
|
102
|
+
foreign_pre_chain.append(structlog.stdlib.add_logger_name)
|
|
103
|
+
|
|
104
|
+
foreign_pre_chain.append(foreign_timestamper)
|
|
105
|
+
|
|
106
|
+
handler.setFormatter(
|
|
107
|
+
structlog.stdlib.ProcessorFormatter(
|
|
108
|
+
processor=renderer,
|
|
109
|
+
foreign_pre_chain=foreign_pre_chain,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Configure root logger (level already set above)
|
|
114
|
+
handlers: list[logging.Handler] = [handler]
|
|
115
|
+
|
|
116
|
+
# Add file handler if log_file is specified
|
|
117
|
+
if log_file:
|
|
118
|
+
from pathlib import Path
|
|
119
|
+
|
|
120
|
+
# Ensure parent directory exists
|
|
121
|
+
log_path = Path(log_file)
|
|
122
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
# Create a file handler that always outputs JSON
|
|
125
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
126
|
+
file_handler.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
|
127
|
+
file_handler.setFormatter(
|
|
128
|
+
structlog.stdlib.ProcessorFormatter(
|
|
129
|
+
processor=structlog.processors.JSONRenderer(),
|
|
130
|
+
foreign_pre_chain=foreign_pre_chain,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
handlers.append(file_handler)
|
|
134
|
+
|
|
135
|
+
root_logger.handlers = handlers
|
|
136
|
+
|
|
137
|
+
# Make sure uvicorn and fastapi loggers use our configuration
|
|
138
|
+
for logger_name in [
|
|
139
|
+
"uvicorn",
|
|
140
|
+
"uvicorn.access",
|
|
141
|
+
"uvicorn.error",
|
|
142
|
+
"fastapi",
|
|
143
|
+
"ccproxy",
|
|
144
|
+
]:
|
|
145
|
+
logger = logging.getLogger(logger_name)
|
|
146
|
+
logger.handlers = [] # Remove default handlers
|
|
147
|
+
logger.propagate = True # Use root logger's handlers
|
|
148
|
+
|
|
149
|
+
# Set uvicorn loggers to WARNING when app log level is INFO to reduce noise
|
|
150
|
+
if logger_name.startswith("uvicorn") and log_level.upper() == "INFO":
|
|
151
|
+
logger.setLevel(logging.WARNING)
|
|
152
|
+
else:
|
|
153
|
+
logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
|
154
|
+
|
|
155
|
+
# Configure httpx logger separately - INFO when app is DEBUG, WARNING otherwise
|
|
156
|
+
httpx_logger = logging.getLogger("httpx")
|
|
157
|
+
httpx_logger.handlers = [] # Remove default handlers
|
|
158
|
+
httpx_logger.propagate = True # Use root logger's handlers
|
|
159
|
+
if log_level.upper() == "DEBUG":
|
|
160
|
+
httpx_logger.setLevel(logging.INFO)
|
|
161
|
+
else:
|
|
162
|
+
httpx_logger.setLevel(logging.WARNING)
|
|
163
|
+
|
|
164
|
+
# Set noisy HTTP-related loggers to WARNING when app log level >= WARNING, else use app log level
|
|
165
|
+
app_log_level = getattr(logging, log_level.upper(), logging.INFO)
|
|
166
|
+
noisy_log_level = (
|
|
167
|
+
logging.WARNING if app_log_level <= logging.WARNING else app_log_level
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
for noisy_logger_name in [
|
|
171
|
+
"urllib3",
|
|
172
|
+
"urllib3.connectionpool",
|
|
173
|
+
"requests",
|
|
174
|
+
"aiohttp",
|
|
175
|
+
"httpcore",
|
|
176
|
+
"httpcore.http11",
|
|
177
|
+
]:
|
|
178
|
+
noisy_logger = logging.getLogger(noisy_logger_name)
|
|
179
|
+
noisy_logger.handlers = [] # Remove default handlers
|
|
180
|
+
noisy_logger.propagate = True # Use root logger's handlers
|
|
181
|
+
noisy_logger.setLevel(noisy_log_level)
|
|
182
|
+
|
|
183
|
+
return structlog.get_logger() # type: ignore[no-any-return]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Create a convenience function for getting loggers
|
|
187
|
+
def get_logger(name: str | None = None) -> BoundLogger:
|
|
188
|
+
"""Get a structlog logger instance."""
|
|
189
|
+
return structlog.get_logger(name) # type: ignore[no-any-return]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Core middleware abstractions for request/response processing."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any, Optional, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from ccproxy.core.types import ProxyRequest, ProxyResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Type alias for the next middleware function
|
|
11
|
+
NextMiddleware = Callable[[ProxyRequest], Awaitable[ProxyResponse]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseMiddleware(ABC):
|
|
15
|
+
"""Abstract base class for all middleware."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def __call__(
|
|
19
|
+
self, request: ProxyRequest, next: NextMiddleware
|
|
20
|
+
) -> ProxyResponse:
|
|
21
|
+
"""Process the request and call the next middleware.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
request: The incoming request
|
|
25
|
+
next: The next middleware in the chain
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The response from the middleware chain
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
MiddlewareError: If middleware processing fails
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class MiddlewareProtocol(Protocol):
|
|
38
|
+
"""Protocol defining the middleware interface."""
|
|
39
|
+
|
|
40
|
+
async def __call__(
|
|
41
|
+
self, request: ProxyRequest, next: NextMiddleware
|
|
42
|
+
) -> ProxyResponse:
|
|
43
|
+
"""Process the request and call the next middleware."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MiddlewareChain:
|
|
48
|
+
"""Manages a chain of middleware."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, middleware: list[BaseMiddleware]):
|
|
51
|
+
"""Initialize with a list of middleware.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
middleware: List of middleware to apply in order
|
|
55
|
+
"""
|
|
56
|
+
self.middleware = middleware
|
|
57
|
+
|
|
58
|
+
async def __call__(
|
|
59
|
+
self, request: ProxyRequest, handler: NextMiddleware
|
|
60
|
+
) -> ProxyResponse:
|
|
61
|
+
"""Execute the middleware chain.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
request: The incoming request
|
|
65
|
+
handler: The final request handler
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The response from the middleware chain
|
|
69
|
+
"""
|
|
70
|
+
# Build the chain from the inside out
|
|
71
|
+
chain = handler
|
|
72
|
+
for mw in reversed(self.middleware):
|
|
73
|
+
# Create a closure to capture the current middleware and chain
|
|
74
|
+
def make_chain(
|
|
75
|
+
current_mw: BaseMiddleware, current_chain: NextMiddleware
|
|
76
|
+
) -> NextMiddleware:
|
|
77
|
+
async def next_fn(req: ProxyRequest) -> ProxyResponse:
|
|
78
|
+
return await current_chain(req)
|
|
79
|
+
|
|
80
|
+
async def new_chain(req: ProxyRequest) -> ProxyResponse:
|
|
81
|
+
return await current_mw(req, next_fn)
|
|
82
|
+
|
|
83
|
+
return new_chain
|
|
84
|
+
|
|
85
|
+
chain = make_chain(mw, chain)
|
|
86
|
+
|
|
87
|
+
# Execute the complete chain
|
|
88
|
+
return await chain(request)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CompositeMiddleware(BaseMiddleware):
|
|
92
|
+
"""Middleware that combines multiple middleware into one."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, middleware: list[BaseMiddleware]):
|
|
95
|
+
"""Initialize with a list of middleware to compose.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
middleware: List of middleware to apply in order
|
|
99
|
+
"""
|
|
100
|
+
self.chain = MiddlewareChain(middleware)
|
|
101
|
+
|
|
102
|
+
async def __call__(
|
|
103
|
+
self, request: ProxyRequest, next: NextMiddleware
|
|
104
|
+
) -> ProxyResponse:
|
|
105
|
+
"""Process the request through all composed middleware.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
request: The incoming request
|
|
109
|
+
next: The next middleware in the chain
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The response from the middleware chain
|
|
113
|
+
"""
|
|
114
|
+
return await self.chain(request, next)
|
ccproxy/core/proxy.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Core proxy abstractions for handling HTTP and WebSocket connections."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Optional, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from ccproxy.core.types import ProxyRequest, ProxyResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ccproxy.core.http import HTTPClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseProxy(ABC):
|
|
14
|
+
"""Abstract base class for all proxy implementations."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def forward(self, request: ProxyRequest) -> ProxyResponse:
|
|
18
|
+
"""Forward a request and return the response.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
request: The proxy request to forward
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The proxy response
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ProxyError: If the request cannot be forwarded
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def close(self) -> None:
|
|
33
|
+
"""Close any resources held by the proxy."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HTTPProxy(BaseProxy):
|
|
38
|
+
"""HTTP proxy implementation using HTTPClient abstractions."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, http_client: "HTTPClient") -> None:
|
|
41
|
+
"""Initialize with an HTTP client.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
http_client: The HTTP client to use for requests
|
|
45
|
+
"""
|
|
46
|
+
self.http_client = http_client
|
|
47
|
+
|
|
48
|
+
async def forward(self, request: ProxyRequest) -> ProxyResponse:
|
|
49
|
+
"""Forward an HTTP request using the HTTP client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
request: The proxy request to forward
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The proxy response
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ProxyError: If the request cannot be forwarded
|
|
59
|
+
"""
|
|
60
|
+
from ccproxy.core.errors import ProxyError
|
|
61
|
+
from ccproxy.core.http import HTTPError
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Convert ProxyRequest to HTTP client format
|
|
65
|
+
body_bytes = None
|
|
66
|
+
if request.body is not None:
|
|
67
|
+
if isinstance(request.body, bytes):
|
|
68
|
+
body_bytes = request.body
|
|
69
|
+
elif isinstance(request.body, str):
|
|
70
|
+
body_bytes = request.body.encode("utf-8")
|
|
71
|
+
elif isinstance(request.body, dict):
|
|
72
|
+
import json
|
|
73
|
+
|
|
74
|
+
body_bytes = json.dumps(request.body).encode("utf-8")
|
|
75
|
+
|
|
76
|
+
# Make the HTTP request
|
|
77
|
+
status_code, headers, response_body = await self.http_client.request(
|
|
78
|
+
method=request.method.value,
|
|
79
|
+
url=request.url,
|
|
80
|
+
headers=request.headers,
|
|
81
|
+
body=body_bytes,
|
|
82
|
+
timeout=request.timeout,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Convert response body to appropriate format
|
|
86
|
+
body: str | bytes | dict[str, Any] | None = response_body
|
|
87
|
+
if response_body:
|
|
88
|
+
# Try to decode as JSON if content-type suggests it
|
|
89
|
+
content_type = headers.get("content-type", "").lower()
|
|
90
|
+
if "application/json" in content_type:
|
|
91
|
+
try:
|
|
92
|
+
import json
|
|
93
|
+
|
|
94
|
+
body = json.loads(response_body.decode("utf-8"))
|
|
95
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
96
|
+
# Keep as bytes if JSON parsing fails
|
|
97
|
+
body = response_body
|
|
98
|
+
elif "text/" in content_type:
|
|
99
|
+
try:
|
|
100
|
+
body = response_body.decode("utf-8")
|
|
101
|
+
except UnicodeDecodeError:
|
|
102
|
+
# Keep as bytes if text decoding fails
|
|
103
|
+
body = response_body
|
|
104
|
+
|
|
105
|
+
return ProxyResponse(
|
|
106
|
+
status_code=status_code,
|
|
107
|
+
headers=headers,
|
|
108
|
+
body=body,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
except HTTPError as e:
|
|
112
|
+
raise ProxyError(f"HTTP request failed: {e}") from e
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise ProxyError(f"Unexpected error during HTTP request: {e}") from e
|
|
115
|
+
|
|
116
|
+
async def close(self) -> None:
|
|
117
|
+
"""Close HTTP proxy resources."""
|
|
118
|
+
await self.http_client.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WebSocketProxy(BaseProxy):
|
|
122
|
+
"""WebSocket proxy implementation placeholder."""
|
|
123
|
+
|
|
124
|
+
async def forward(self, request: ProxyRequest) -> ProxyResponse:
|
|
125
|
+
"""Forward a WebSocket request."""
|
|
126
|
+
raise NotImplementedError("WebSocketProxy.forward not yet implemented")
|
|
127
|
+
|
|
128
|
+
async def close(self) -> None:
|
|
129
|
+
"""Close WebSocket proxy resources."""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@runtime_checkable
|
|
134
|
+
class ProxyProtocol(Protocol):
|
|
135
|
+
"""Protocol defining the proxy interface."""
|
|
136
|
+
|
|
137
|
+
async def forward(self, request: ProxyRequest) -> ProxyResponse:
|
|
138
|
+
"""Forward a request and return the response."""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
"""Close any resources held by the proxy."""
|
|
143
|
+
...
|
ccproxy/core/system.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_xdg_config_home() -> Path:
|
|
6
|
+
"""Get the XDG_CONFIG_HOME directory.
|
|
7
|
+
|
|
8
|
+
Returns:
|
|
9
|
+
Path to the XDG config directory. Falls back to ~/.config if not set.
|
|
10
|
+
"""
|
|
11
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
12
|
+
if xdg_config_home:
|
|
13
|
+
return Path(xdg_config_home)
|
|
14
|
+
return Path.home() / ".config"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_xdg_data_home() -> Path:
|
|
18
|
+
"""Get the XDG_DATA_HOME directory.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Path to the XDG data directory. Falls back to ~/.local/share if not set.
|
|
22
|
+
"""
|
|
23
|
+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
|
24
|
+
if xdg_data_home:
|
|
25
|
+
return Path(xdg_data_home)
|
|
26
|
+
return Path.home() / ".local" / "share"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_xdg_cache_home() -> Path:
|
|
30
|
+
"""Get the XDG_CACHE_HOME directory.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to the XDG cache directory. Falls back to ~/.cache if not set.
|
|
34
|
+
"""
|
|
35
|
+
xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
|
|
36
|
+
if xdg_cache_home:
|
|
37
|
+
return Path(xdg_cache_home)
|
|
38
|
+
return Path.home() / ".cache"
|