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,51 @@
|
|
|
1
|
+
"""Header preservation middleware to maintain proxy response headers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, Response
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
7
|
+
from starlette.types import ASGIApp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HeaderPreservationMiddleware(BaseHTTPMiddleware):
|
|
11
|
+
"""Middleware to preserve certain headers from proxy responses.
|
|
12
|
+
|
|
13
|
+
This middleware ensures that headers like 'server' from the upstream
|
|
14
|
+
API are preserved and not overridden by Uvicorn/Starlette.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, app: ASGIApp):
|
|
18
|
+
"""Initialize the header preservation middleware.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
app: The ASGI application
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(app)
|
|
24
|
+
|
|
25
|
+
async def dispatch(
|
|
26
|
+
self, request: Request, call_next: RequestResponseEndpoint
|
|
27
|
+
) -> Response:
|
|
28
|
+
"""Process the request and preserve specific headers.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request: The incoming HTTP request
|
|
32
|
+
call_next: The next middleware/handler in the chain
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The HTTP response with preserved headers
|
|
36
|
+
"""
|
|
37
|
+
# Process the request
|
|
38
|
+
response = await call_next(request)
|
|
39
|
+
|
|
40
|
+
# Check if we have a stored server header to preserve
|
|
41
|
+
# This would be set by the proxy service if we want to preserve it
|
|
42
|
+
if hasattr(request.state, "preserve_headers"):
|
|
43
|
+
for header_name, header_value in request.state.preserve_headers.items():
|
|
44
|
+
# Force set the header to override any default values
|
|
45
|
+
response.headers[header_name] = header_value
|
|
46
|
+
# Also try raw header setting for more control
|
|
47
|
+
response.raw_headers.append(
|
|
48
|
+
(header_name.encode(), header_value.encode())
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return response
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Access logging middleware for structured HTTP request/response logging."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from fastapi import Request, Response
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AccessLogMiddleware(BaseHTTPMiddleware):
|
|
16
|
+
"""Middleware for structured access logging with request/response details."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, app: ASGIApp):
|
|
19
|
+
"""Initialize the access log middleware.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
app: The ASGI application
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(app)
|
|
25
|
+
|
|
26
|
+
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
27
|
+
"""Process the request and log access details.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
request: The incoming HTTP request
|
|
31
|
+
call_next: The next middleware/handler in the chain
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The HTTP response
|
|
35
|
+
"""
|
|
36
|
+
# Record start time
|
|
37
|
+
start_time = time.perf_counter()
|
|
38
|
+
|
|
39
|
+
# Store log storage in request state if collection is enabled
|
|
40
|
+
from ccproxy.config.settings import get_settings
|
|
41
|
+
|
|
42
|
+
settings = get_settings()
|
|
43
|
+
|
|
44
|
+
if settings.observability.logs_collection_enabled and hasattr(
|
|
45
|
+
request.app.state, "log_storage"
|
|
46
|
+
):
|
|
47
|
+
request.state.log_storage = request.app.state.log_storage
|
|
48
|
+
|
|
49
|
+
# Extract client info
|
|
50
|
+
client_ip = "unknown"
|
|
51
|
+
if request.client:
|
|
52
|
+
client_ip = request.client.host
|
|
53
|
+
|
|
54
|
+
# Extract request info
|
|
55
|
+
method = request.method
|
|
56
|
+
path = str(request.url.path)
|
|
57
|
+
query = str(request.url.query) if request.url.query else None
|
|
58
|
+
user_agent = request.headers.get("user-agent", "unknown")
|
|
59
|
+
|
|
60
|
+
# Get request ID from context if available
|
|
61
|
+
request_id: str | None = None
|
|
62
|
+
try:
|
|
63
|
+
if hasattr(request.state, "request_id"):
|
|
64
|
+
request_id = request.state.request_id
|
|
65
|
+
elif hasattr(request.state, "context"):
|
|
66
|
+
# Try to check if it's a RequestContext without importing
|
|
67
|
+
context = request.state.context
|
|
68
|
+
if hasattr(context, "request_id") and hasattr(context, "metadata"):
|
|
69
|
+
request_id = context.request_id
|
|
70
|
+
except Exception:
|
|
71
|
+
# Ignore any errors getting request_id
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Process the request
|
|
75
|
+
response: Response | None = None
|
|
76
|
+
error_message: str | None = None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
response = await call_next(request)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
# Capture error for logging
|
|
82
|
+
error_message = str(e)
|
|
83
|
+
# Re-raise to let error handlers process it
|
|
84
|
+
raise
|
|
85
|
+
finally:
|
|
86
|
+
try:
|
|
87
|
+
# Calculate duration
|
|
88
|
+
duration_seconds = time.perf_counter() - start_time
|
|
89
|
+
duration_ms = duration_seconds * 1000
|
|
90
|
+
|
|
91
|
+
# Extract response info
|
|
92
|
+
if response:
|
|
93
|
+
status_code = response.status_code
|
|
94
|
+
|
|
95
|
+
# Extract rate limit headers if present
|
|
96
|
+
rate_limit_info = {}
|
|
97
|
+
anthropic_request_id = None
|
|
98
|
+
for header_name, header_value in response.headers.items():
|
|
99
|
+
header_lower = header_name.lower()
|
|
100
|
+
# Capture x-ratelimit-* headers
|
|
101
|
+
if header_lower.startswith(
|
|
102
|
+
"x-ratelimit-"
|
|
103
|
+
) or header_lower.startswith("anthropic-ratelimit-"):
|
|
104
|
+
rate_limit_info[header_lower] = header_value
|
|
105
|
+
# Capture request-id from Anthropic's response
|
|
106
|
+
elif header_lower == "request-id":
|
|
107
|
+
anthropic_request_id = header_value
|
|
108
|
+
|
|
109
|
+
# Add anthropic request ID if present
|
|
110
|
+
if anthropic_request_id:
|
|
111
|
+
rate_limit_info["anthropic_request_id"] = anthropic_request_id
|
|
112
|
+
|
|
113
|
+
# Extract metadata from context if available
|
|
114
|
+
context_metadata = {}
|
|
115
|
+
try:
|
|
116
|
+
if hasattr(request.state, "context"):
|
|
117
|
+
context = request.state.context
|
|
118
|
+
# Check if it has the expected attributes of RequestContext
|
|
119
|
+
if hasattr(context, "metadata") and isinstance(
|
|
120
|
+
context.metadata, dict
|
|
121
|
+
):
|
|
122
|
+
# Get all metadata from the context
|
|
123
|
+
context_metadata = context.metadata.copy()
|
|
124
|
+
# Remove fields we're already logging separately
|
|
125
|
+
for key in [
|
|
126
|
+
"method",
|
|
127
|
+
"path",
|
|
128
|
+
"client_ip",
|
|
129
|
+
"status_code",
|
|
130
|
+
"request_id",
|
|
131
|
+
"duration_ms",
|
|
132
|
+
"duration_seconds",
|
|
133
|
+
"query",
|
|
134
|
+
"user_agent",
|
|
135
|
+
"error_message",
|
|
136
|
+
]:
|
|
137
|
+
context_metadata.pop(key, None)
|
|
138
|
+
except Exception:
|
|
139
|
+
# Ignore any errors extracting context metadata
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# Use start-only logging - let context handle comprehensive access logging
|
|
143
|
+
# Only log basic request start info since context will handle complete access log
|
|
144
|
+
from ccproxy.observability.access_logger import log_request_start
|
|
145
|
+
|
|
146
|
+
log_request_start(
|
|
147
|
+
request_id=request_id or "unknown",
|
|
148
|
+
method=method,
|
|
149
|
+
path=path,
|
|
150
|
+
client_ip=client_ip,
|
|
151
|
+
user_agent=user_agent,
|
|
152
|
+
query=query,
|
|
153
|
+
**rate_limit_info,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
# Log error case
|
|
157
|
+
logger.error(
|
|
158
|
+
"access_log_error",
|
|
159
|
+
request_id=request_id,
|
|
160
|
+
method=method,
|
|
161
|
+
path=path,
|
|
162
|
+
query=query,
|
|
163
|
+
client_ip=client_ip,
|
|
164
|
+
user_agent=user_agent,
|
|
165
|
+
duration_ms=duration_ms,
|
|
166
|
+
duration_seconds=duration_seconds,
|
|
167
|
+
error_message=error_message or "No response generated",
|
|
168
|
+
exc_info=True,
|
|
169
|
+
)
|
|
170
|
+
except Exception as log_error:
|
|
171
|
+
# If logging fails, don't crash the app
|
|
172
|
+
# Use print as a last resort to indicate the issue
|
|
173
|
+
print(f"Failed to write access log: {log_error}")
|
|
174
|
+
|
|
175
|
+
return response
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Request ID middleware for generating and tracking request IDs."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from fastapi import Request, Response
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
from ccproxy.observability.context import request_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
18
|
+
"""Middleware for generating request IDs and initializing request context."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, app: ASGIApp):
|
|
21
|
+
"""Initialize the request ID middleware.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
app: The ASGI application
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(app)
|
|
27
|
+
|
|
28
|
+
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
29
|
+
"""Process the request and add request ID/context.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
request: The incoming HTTP request
|
|
33
|
+
call_next: The next middleware/handler in the chain
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The HTTP response
|
|
37
|
+
"""
|
|
38
|
+
# Generate or extract request ID
|
|
39
|
+
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
|
40
|
+
|
|
41
|
+
# Get DuckDB storage from app state if available
|
|
42
|
+
storage = getattr(request.app.state, "duckdb_storage", None)
|
|
43
|
+
|
|
44
|
+
# Use the proper request context manager to ensure __aexit__ is called
|
|
45
|
+
async with request_context(
|
|
46
|
+
request_id=request_id,
|
|
47
|
+
storage=storage,
|
|
48
|
+
method=request.method,
|
|
49
|
+
path=str(request.url.path),
|
|
50
|
+
client_ip=request.client.host if request.client else "unknown",
|
|
51
|
+
user_agent=request.headers.get("user-agent", "unknown"),
|
|
52
|
+
query=str(request.url.query) if request.url.query else None,
|
|
53
|
+
service_type="access_log",
|
|
54
|
+
) as ctx:
|
|
55
|
+
# Store context in request state for access by services
|
|
56
|
+
request.state.request_id = request_id
|
|
57
|
+
request.state.context = ctx
|
|
58
|
+
|
|
59
|
+
# Add DuckDB storage to context if available
|
|
60
|
+
if hasattr(request.state, "duckdb_storage"):
|
|
61
|
+
ctx.storage = request.state.duckdb_storage
|
|
62
|
+
|
|
63
|
+
# Process the request
|
|
64
|
+
response = await call_next(request)
|
|
65
|
+
|
|
66
|
+
# Add request ID to response headers
|
|
67
|
+
response.headers["x-request-id"] = request_id
|
|
68
|
+
|
|
69
|
+
return response # type: ignore[no-any-return]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Server header middleware to set a default server header for non-proxy routes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, Response
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServerHeaderMiddleware:
|
|
11
|
+
"""Middleware to set a default server header for responses.
|
|
12
|
+
|
|
13
|
+
This middleware adds a server header to responses that don't already have one.
|
|
14
|
+
Proxy responses using ProxyResponse will preserve their upstream server header,
|
|
15
|
+
while other routes will get the default header.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, app: ASGIApp, server_name: str = "Claude Code Proxy"):
|
|
19
|
+
"""Initialize the server header middleware.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
app: The ASGI application
|
|
23
|
+
server_name: The default server name to use
|
|
24
|
+
"""
|
|
25
|
+
self.app = app
|
|
26
|
+
self.server_name = server_name
|
|
27
|
+
|
|
28
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
29
|
+
"""ASGI application entrypoint."""
|
|
30
|
+
if scope["type"] != "http":
|
|
31
|
+
await self.app(scope, receive, send)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
async def send_wrapper(message: Message) -> None:
|
|
35
|
+
if message["type"] == "http.response.start":
|
|
36
|
+
headers = list(message.get("headers", []))
|
|
37
|
+
|
|
38
|
+
# Check if server header already exists
|
|
39
|
+
has_server = any(header[0].lower() == b"server" for header in headers)
|
|
40
|
+
|
|
41
|
+
# Only add server header for non-proxy routes
|
|
42
|
+
# Proxy routes will have their server header preserved from upstream
|
|
43
|
+
if not has_server:
|
|
44
|
+
# Check if this looks like a proxy response by looking for specific headers
|
|
45
|
+
is_proxy_response = any(
|
|
46
|
+
header[0].lower()
|
|
47
|
+
in [
|
|
48
|
+
b"cf-ray",
|
|
49
|
+
b"cf-cache-status",
|
|
50
|
+
b"anthropic-ratelimit-unified-status",
|
|
51
|
+
]
|
|
52
|
+
for header in headers
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Only add our server header if this is NOT a proxy response
|
|
56
|
+
if not is_proxy_response:
|
|
57
|
+
headers.append((b"server", self.server_name.encode()))
|
|
58
|
+
message["headers"] = headers
|
|
59
|
+
|
|
60
|
+
await send(message)
|
|
61
|
+
|
|
62
|
+
await self.app(scope, receive, send_wrapper)
|
ccproxy/api/responses.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Custom response classes for preserving proxy headers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Response
|
|
6
|
+
from starlette.types import Receive, Scope, Send
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProxyResponse(Response):
|
|
10
|
+
"""Custom response class that preserves all headers from upstream API.
|
|
11
|
+
|
|
12
|
+
This response class ensures that headers like 'server' from the upstream
|
|
13
|
+
API are preserved and not overridden by Uvicorn/Starlette.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
content: Any = None,
|
|
19
|
+
status_code: int = 200,
|
|
20
|
+
headers: dict[str, str] | None = None,
|
|
21
|
+
media_type: str | None = None,
|
|
22
|
+
background: Any = None,
|
|
23
|
+
):
|
|
24
|
+
"""Initialize the proxy response with preserved headers.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
content: Response content
|
|
28
|
+
status_code: HTTP status code
|
|
29
|
+
headers: Headers to preserve from upstream
|
|
30
|
+
media_type: Content type
|
|
31
|
+
background: Background task
|
|
32
|
+
"""
|
|
33
|
+
super().__init__(
|
|
34
|
+
content=content,
|
|
35
|
+
status_code=status_code,
|
|
36
|
+
headers=headers,
|
|
37
|
+
media_type=media_type,
|
|
38
|
+
background=background,
|
|
39
|
+
)
|
|
40
|
+
# Store original headers for preservation
|
|
41
|
+
self._preserve_headers = headers or {}
|
|
42
|
+
|
|
43
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
44
|
+
"""Override the ASGI call to ensure headers are preserved.
|
|
45
|
+
|
|
46
|
+
This method intercepts the response sending process to ensure
|
|
47
|
+
that our headers are not overridden by the server.
|
|
48
|
+
"""
|
|
49
|
+
# Ensure we include all original headers, including 'server'
|
|
50
|
+
headers_list = []
|
|
51
|
+
|
|
52
|
+
# Add all headers from the response, but skip content-length
|
|
53
|
+
# as we'll recalculate it based on actual body
|
|
54
|
+
for name, value in self._preserve_headers.items():
|
|
55
|
+
lower_name = name.lower()
|
|
56
|
+
# Skip content-length and transfer-encoding as we'll set them correctly
|
|
57
|
+
if lower_name not in ["content-length", "transfer-encoding"]:
|
|
58
|
+
headers_list.append((lower_name.encode(), value.encode()))
|
|
59
|
+
|
|
60
|
+
# Always set correct content-length based on actual body
|
|
61
|
+
if self.body:
|
|
62
|
+
headers_list.append((b"content-length", str(len(self.body)).encode()))
|
|
63
|
+
else:
|
|
64
|
+
headers_list.append((b"content-length", b"0"))
|
|
65
|
+
|
|
66
|
+
# Ensure we have content-type
|
|
67
|
+
has_content_type = any(h[0] == b"content-type" for h in headers_list)
|
|
68
|
+
if not has_content_type and self.media_type:
|
|
69
|
+
headers_list.append((b"content-type", self.media_type.encode()))
|
|
70
|
+
|
|
71
|
+
await send(
|
|
72
|
+
{
|
|
73
|
+
"type": "http.response.start",
|
|
74
|
+
"status": self.status_code,
|
|
75
|
+
"headers": headers_list,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
await send(
|
|
80
|
+
{
|
|
81
|
+
"type": "http.response.body",
|
|
82
|
+
"body": self.body,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""API routes for CCProxy API Server."""
|
|
2
|
+
|
|
3
|
+
# from ccproxy.api.routes.auth import router as auth_router # Module doesn't exist
|
|
4
|
+
from ccproxy.api.routes.claude import router as claude_router
|
|
5
|
+
from ccproxy.api.routes.health import router as health_router
|
|
6
|
+
from ccproxy.api.routes.metrics import router as metrics_router
|
|
7
|
+
from ccproxy.api.routes.proxy import router as proxy_router
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
# "auth_router", # Module doesn't exist
|
|
12
|
+
"claude_router",
|
|
13
|
+
"health_router",
|
|
14
|
+
"metrics_router",
|
|
15
|
+
"proxy_router",
|
|
16
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Claude SDK endpoints for CCProxy API Server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
9
|
+
from fastapi.responses import StreamingResponse
|
|
10
|
+
|
|
11
|
+
from ccproxy.adapters.openai.adapter import (
|
|
12
|
+
OpenAIAdapter,
|
|
13
|
+
OpenAIChatCompletionRequest,
|
|
14
|
+
OpenAIChatCompletionResponse,
|
|
15
|
+
)
|
|
16
|
+
from ccproxy.api.dependencies import get_claude_service
|
|
17
|
+
from ccproxy.models.messages import MessageCreateParams, MessageResponse
|
|
18
|
+
from ccproxy.services.claude_sdk_service import ClaudeSDKService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Create the router for Claude SDK endpoints
|
|
22
|
+
router = APIRouter(tags=["claude-sdk"])
|
|
23
|
+
|
|
24
|
+
logger = structlog.get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/v1/chat/completions", response_model=None)
|
|
28
|
+
async def create_openai_chat_completion(
|
|
29
|
+
request: Request,
|
|
30
|
+
openai_request: OpenAIChatCompletionRequest,
|
|
31
|
+
claude_service: ClaudeSDKService = Depends(get_claude_service),
|
|
32
|
+
) -> StreamingResponse | OpenAIChatCompletionResponse:
|
|
33
|
+
"""Create a chat completion using Claude SDK with OpenAI-compatible format.
|
|
34
|
+
|
|
35
|
+
This endpoint handles OpenAI API format requests and converts them
|
|
36
|
+
to Anthropic format before using the Claude SDK directly.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Create adapter instance
|
|
40
|
+
adapter = OpenAIAdapter()
|
|
41
|
+
|
|
42
|
+
# Convert entire OpenAI request to Anthropic format using adapter
|
|
43
|
+
anthropic_request = adapter.adapt_request(openai_request.model_dump())
|
|
44
|
+
|
|
45
|
+
# Extract stream parameter
|
|
46
|
+
stream = openai_request.stream or False
|
|
47
|
+
|
|
48
|
+
# Call Claude SDK service with adapted request
|
|
49
|
+
if request and hasattr(request, "state") and hasattr(request.state, "context"):
|
|
50
|
+
# Use existing context from middleware
|
|
51
|
+
ctx = request.state.context
|
|
52
|
+
# Add service-specific metadata
|
|
53
|
+
ctx.add_metadata(streaming=stream)
|
|
54
|
+
|
|
55
|
+
response = await claude_service.create_completion(
|
|
56
|
+
messages=anthropic_request["messages"],
|
|
57
|
+
model=anthropic_request["model"],
|
|
58
|
+
temperature=anthropic_request.get("temperature"),
|
|
59
|
+
max_tokens=anthropic_request.get("max_tokens"),
|
|
60
|
+
stream=stream,
|
|
61
|
+
user_id=getattr(openai_request, "user", None),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if stream:
|
|
65
|
+
# Handle streaming response
|
|
66
|
+
async def openai_stream_generator() -> AsyncIterator[bytes]:
|
|
67
|
+
# Use adapt_stream for streaming responses
|
|
68
|
+
async for openai_chunk in adapter.adapt_stream(response): # type: ignore[arg-type]
|
|
69
|
+
yield f"data: {json.dumps(openai_chunk)}\n\n".encode()
|
|
70
|
+
# Send final chunk
|
|
71
|
+
yield b"data: [DONE]\n\n"
|
|
72
|
+
|
|
73
|
+
return StreamingResponse(
|
|
74
|
+
openai_stream_generator(),
|
|
75
|
+
media_type="text/event-stream",
|
|
76
|
+
headers={
|
|
77
|
+
"Cache-Control": "no-cache",
|
|
78
|
+
"Connection": "keep-alive",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
# Convert non-streaming response to OpenAI format using adapter
|
|
83
|
+
openai_response = adapter.adapt_response(response) # type: ignore[arg-type]
|
|
84
|
+
return OpenAIChatCompletionResponse.model_validate(openai_response)
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
# Re-raise specific proxy errors to be handled by the error handler
|
|
88
|
+
from ccproxy.core.errors import ClaudeProxyError
|
|
89
|
+
|
|
90
|
+
if isinstance(e, ClaudeProxyError):
|
|
91
|
+
raise
|
|
92
|
+
raise HTTPException(
|
|
93
|
+
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
94
|
+
) from e
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/v1/messages", response_model=None)
|
|
98
|
+
async def create_anthropic_message(
|
|
99
|
+
request: MessageCreateParams,
|
|
100
|
+
claude_service: ClaudeSDKService = Depends(get_claude_service),
|
|
101
|
+
) -> StreamingResponse | MessageResponse:
|
|
102
|
+
"""Create a message using Claude SDK with Anthropic format.
|
|
103
|
+
|
|
104
|
+
This endpoint handles Anthropic API format requests directly
|
|
105
|
+
using the Claude SDK without any format conversion.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
# Extract parameters from Anthropic request
|
|
109
|
+
messages = [msg.model_dump() for msg in request.messages]
|
|
110
|
+
model = request.model
|
|
111
|
+
temperature = request.temperature
|
|
112
|
+
max_tokens = request.max_tokens
|
|
113
|
+
stream = request.stream or False
|
|
114
|
+
|
|
115
|
+
# Call Claude SDK service directly with Anthropic format
|
|
116
|
+
response = await claude_service.create_completion(
|
|
117
|
+
messages=messages,
|
|
118
|
+
model=model,
|
|
119
|
+
temperature=temperature,
|
|
120
|
+
max_tokens=max_tokens,
|
|
121
|
+
stream=stream,
|
|
122
|
+
user_id=getattr(request, "user_id", None),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if stream:
|
|
126
|
+
# Handle streaming response
|
|
127
|
+
async def anthropic_stream_generator() -> AsyncIterator[bytes]:
|
|
128
|
+
async for chunk in response: # type: ignore[union-attr]
|
|
129
|
+
if chunk:
|
|
130
|
+
yield f"data: {json.dumps(chunk)}\n\n".encode()
|
|
131
|
+
# Send final chunk
|
|
132
|
+
yield b"data: [DONE]\n\n"
|
|
133
|
+
|
|
134
|
+
return StreamingResponse(
|
|
135
|
+
anthropic_stream_generator(),
|
|
136
|
+
media_type="text/event-stream",
|
|
137
|
+
headers={
|
|
138
|
+
"Cache-Control": "no-cache",
|
|
139
|
+
"Connection": "keep-alive",
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
# Return Anthropic format response directly
|
|
144
|
+
return MessageResponse.model_validate(response)
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
# Re-raise specific proxy errors to be handled by the error handler
|
|
148
|
+
from ccproxy.core.errors import ClaudeProxyError
|
|
149
|
+
|
|
150
|
+
if isinstance(e, ClaudeProxyError):
|
|
151
|
+
raise
|
|
152
|
+
raise HTTPException(
|
|
153
|
+
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
154
|
+
) from e
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.get("/v1/models", response_model=None)
|
|
158
|
+
async def list_models(
|
|
159
|
+
claude_service: ClaudeSDKService = Depends(get_claude_service),
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""List available models using Claude SDK service.
|
|
162
|
+
|
|
163
|
+
Returns a combined list of Anthropic models and recent OpenAI models.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
return await claude_service.list_models()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
# Re-raise specific proxy errors to be handled by the error handler
|
|
169
|
+
from ccproxy.core.errors import ClaudeProxyError
|
|
170
|
+
|
|
171
|
+
if isinstance(e, ClaudeProxyError):
|
|
172
|
+
raise
|
|
173
|
+
raise HTTPException(
|
|
174
|
+
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
175
|
+
) from e
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@router.get("/status")
|
|
179
|
+
async def claude_sdk_status() -> dict[str, str]:
|
|
180
|
+
"""Get Claude SDK status."""
|
|
181
|
+
return {"status": "claude sdk endpoint available", "service": "direct"}
|