ccproxy-api 0.1.2__py3-none-any.whl → 0.1.4__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/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +62 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +76 -29
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -2
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +30 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +5 -16
- ccproxy/cli/options/claude_options.py +19 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +13 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +29 -2
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +220 -328
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.4.dist-info/RECORD +166 -0
- ccproxy/cli/commands/permission.py +0 -128
- ccproxy_api-0.1.2.dist-info/RECORD +0 -150
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Request content logging middleware for capturing full HTTP request/response data."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from fastapi import Request, Response
|
|
9
|
+
from fastapi.responses import StreamingResponse
|
|
10
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
|
+
from starlette.types import ASGIApp
|
|
12
|
+
|
|
13
|
+
from ccproxy.utils.simple_request_logger import (
|
|
14
|
+
append_streaming_log,
|
|
15
|
+
write_request_log,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestContentLoggingMiddleware(BaseHTTPMiddleware):
|
|
23
|
+
"""Middleware for logging full HTTP request and response content."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, app: ASGIApp):
|
|
26
|
+
"""Initialize the request content logging middleware.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
app: The ASGI application
|
|
30
|
+
"""
|
|
31
|
+
super().__init__(app)
|
|
32
|
+
|
|
33
|
+
async def dispatch(self, request: Request, call_next: Any) -> Any:
|
|
34
|
+
"""Process the request and log content.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
request: The incoming HTTP request
|
|
38
|
+
call_next: The next middleware/handler in the chain
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The HTTP response
|
|
42
|
+
"""
|
|
43
|
+
# Get request ID and timestamp from context if available
|
|
44
|
+
request_id = self._get_request_id(request)
|
|
45
|
+
timestamp = self._get_timestamp_prefix(request)
|
|
46
|
+
|
|
47
|
+
# Log incoming request
|
|
48
|
+
await self._log_request(request, request_id, timestamp)
|
|
49
|
+
|
|
50
|
+
# Process the request
|
|
51
|
+
response = await call_next(request)
|
|
52
|
+
|
|
53
|
+
# Log outgoing response
|
|
54
|
+
await self._log_response(response, request_id, timestamp)
|
|
55
|
+
|
|
56
|
+
return response
|
|
57
|
+
|
|
58
|
+
def _get_request_id(self, request: Request) -> str:
|
|
59
|
+
"""Extract request ID from request state or context.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
request: The HTTP request
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Request ID string or 'unknown' if not found
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
# Try to get from request state
|
|
69
|
+
if hasattr(request.state, "request_id"):
|
|
70
|
+
return str(request.state.request_id)
|
|
71
|
+
|
|
72
|
+
# Try to get from request context
|
|
73
|
+
if hasattr(request.state, "context"):
|
|
74
|
+
context = request.state.context
|
|
75
|
+
if hasattr(context, "request_id"):
|
|
76
|
+
return str(context.request_id)
|
|
77
|
+
|
|
78
|
+
# Fallback to UUID if available in headers
|
|
79
|
+
if "x-request-id" in request.headers:
|
|
80
|
+
return request.headers["x-request-id"]
|
|
81
|
+
|
|
82
|
+
except Exception:
|
|
83
|
+
pass # Ignore errors and use fallback
|
|
84
|
+
|
|
85
|
+
return "unknown"
|
|
86
|
+
|
|
87
|
+
def _get_timestamp_prefix(self, request: Request) -> str | None:
|
|
88
|
+
"""Extract timestamp prefix from request context.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
request: The HTTP request
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Timestamp prefix string or None if not found
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
# Try to get from request context
|
|
98
|
+
if hasattr(request.state, "context"):
|
|
99
|
+
context = request.state.context
|
|
100
|
+
if hasattr(context, "get_log_timestamp_prefix"):
|
|
101
|
+
result = context.get_log_timestamp_prefix()
|
|
102
|
+
return str(result) if result is not None else None
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # Ignore errors and use fallback
|
|
105
|
+
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
async def _log_request(
|
|
109
|
+
self, request: Request, request_id: str, timestamp: str | None
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Log incoming HTTP request content.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
request: The HTTP request
|
|
115
|
+
request_id: Request identifier
|
|
116
|
+
timestamp: Timestamp prefix for the log file
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Read request body
|
|
120
|
+
body = await request.body()
|
|
121
|
+
|
|
122
|
+
# Create request log data
|
|
123
|
+
request_data = {
|
|
124
|
+
"method": request.method,
|
|
125
|
+
"url": str(request.url),
|
|
126
|
+
"headers": dict(request.headers),
|
|
127
|
+
"query_params": dict(request.query_params),
|
|
128
|
+
"path_params": dict(request.path_params)
|
|
129
|
+
if hasattr(request, "path_params")
|
|
130
|
+
else {},
|
|
131
|
+
"body_size": len(body) if body else 0,
|
|
132
|
+
"body": None,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Try to parse body as JSON, fallback to string
|
|
136
|
+
if body:
|
|
137
|
+
try:
|
|
138
|
+
request_data["body"] = json.loads(body.decode("utf-8"))
|
|
139
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
140
|
+
try:
|
|
141
|
+
request_data["body"] = body.decode("utf-8", errors="replace")
|
|
142
|
+
except Exception:
|
|
143
|
+
request_data["body"] = f"<binary data of length {len(body)}>"
|
|
144
|
+
|
|
145
|
+
await write_request_log(
|
|
146
|
+
request_id=request_id,
|
|
147
|
+
log_type="middleware_request",
|
|
148
|
+
data=request_data,
|
|
149
|
+
timestamp=timestamp,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(
|
|
154
|
+
"failed_to_log_request_content",
|
|
155
|
+
request_id=request_id,
|
|
156
|
+
error=str(e),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def _log_response(
|
|
160
|
+
self, response: Response, request_id: str, timestamp: str | None
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Log outgoing HTTP response content.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
response: The HTTP response
|
|
166
|
+
request_id: Request identifier
|
|
167
|
+
timestamp: Timestamp prefix for the log file
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
if isinstance(response, StreamingResponse):
|
|
171
|
+
# Handle streaming response
|
|
172
|
+
await self._log_streaming_response(response, request_id, timestamp)
|
|
173
|
+
else:
|
|
174
|
+
# Handle regular response
|
|
175
|
+
await self._log_regular_response(response, request_id, timestamp)
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(
|
|
179
|
+
"failed_to_log_response_content",
|
|
180
|
+
request_id=request_id,
|
|
181
|
+
error=str(e),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def _log_regular_response(
|
|
185
|
+
self, response: Response, request_id: str, timestamp: str | None
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Log regular (non-streaming) HTTP response.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
response: The HTTP response
|
|
191
|
+
request_id: Request identifier
|
|
192
|
+
timestamp: Timestamp prefix for the log file
|
|
193
|
+
"""
|
|
194
|
+
# Create response log data
|
|
195
|
+
response_data = {
|
|
196
|
+
"status_code": response.status_code,
|
|
197
|
+
"headers": dict(response.headers),
|
|
198
|
+
"body": None,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Try to get response body
|
|
202
|
+
if hasattr(response, "body") and response.body:
|
|
203
|
+
body = response.body
|
|
204
|
+
response_data["body_size"] = len(body)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
# Convert to bytes if needed
|
|
208
|
+
body_bytes = bytes(body) if isinstance(body, memoryview) else body
|
|
209
|
+
# Try to parse as JSON
|
|
210
|
+
response_data["body"] = json.loads(body_bytes.decode("utf-8"))
|
|
211
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
212
|
+
try:
|
|
213
|
+
# Fallback to string
|
|
214
|
+
body_bytes = bytes(body) if isinstance(body, memoryview) else body
|
|
215
|
+
response_data["body"] = body_bytes.decode("utf-8", errors="replace")
|
|
216
|
+
except Exception:
|
|
217
|
+
response_data["body"] = f"<binary data of length {len(body)}>"
|
|
218
|
+
else:
|
|
219
|
+
response_data["body_size"] = 0
|
|
220
|
+
|
|
221
|
+
await write_request_log(
|
|
222
|
+
request_id=request_id,
|
|
223
|
+
log_type="middleware_response",
|
|
224
|
+
data=response_data,
|
|
225
|
+
timestamp=timestamp,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def _log_streaming_response(
|
|
229
|
+
self, response: StreamingResponse, request_id: str, timestamp: str | None
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Log streaming HTTP response by wrapping the stream.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
response: The streaming HTTP response
|
|
235
|
+
request_id: Request identifier
|
|
236
|
+
timestamp: Timestamp prefix for the log file
|
|
237
|
+
"""
|
|
238
|
+
# Log response metadata first
|
|
239
|
+
response_data = {
|
|
240
|
+
"status_code": response.status_code,
|
|
241
|
+
"headers": dict(response.headers),
|
|
242
|
+
"body_type": "streaming",
|
|
243
|
+
"media_type": response.media_type,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await write_request_log(
|
|
247
|
+
request_id=request_id,
|
|
248
|
+
log_type="middleware_response",
|
|
249
|
+
data=response_data,
|
|
250
|
+
timestamp=timestamp,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Wrap the streaming response to capture content
|
|
254
|
+
original_body_iterator = response.body_iterator
|
|
255
|
+
|
|
256
|
+
def create_logged_body_iterator() -> AsyncGenerator[
|
|
257
|
+
str | bytes | memoryview[int], None
|
|
258
|
+
]:
|
|
259
|
+
"""Create wrapper around the original body iterator to log streaming content."""
|
|
260
|
+
|
|
261
|
+
async def logged_body_iterator() -> AsyncGenerator[
|
|
262
|
+
str | bytes | memoryview[int], None
|
|
263
|
+
]:
|
|
264
|
+
try:
|
|
265
|
+
async for chunk in original_body_iterator:
|
|
266
|
+
# Log chunk as raw data
|
|
267
|
+
if isinstance(chunk, bytes | bytearray):
|
|
268
|
+
await append_streaming_log(
|
|
269
|
+
request_id=request_id,
|
|
270
|
+
log_type="middleware_streaming",
|
|
271
|
+
data=bytes(chunk),
|
|
272
|
+
timestamp=timestamp,
|
|
273
|
+
)
|
|
274
|
+
elif isinstance(chunk, str):
|
|
275
|
+
await append_streaming_log(
|
|
276
|
+
request_id=request_id,
|
|
277
|
+
log_type="middleware_streaming",
|
|
278
|
+
data=chunk.encode("utf-8"),
|
|
279
|
+
timestamp=timestamp,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
yield chunk
|
|
283
|
+
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.error(
|
|
286
|
+
"error_in_streaming_response_logging",
|
|
287
|
+
request_id=request_id,
|
|
288
|
+
error=str(e),
|
|
289
|
+
)
|
|
290
|
+
# Continue with original iterator if logging fails
|
|
291
|
+
async for chunk in original_body_iterator:
|
|
292
|
+
yield chunk
|
|
293
|
+
|
|
294
|
+
return logged_body_iterator()
|
|
295
|
+
|
|
296
|
+
# Replace the body iterator with our logged version
|
|
297
|
+
response.body_iterator = create_logged_body_iterator()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Request ID middleware for generating and tracking request IDs."""
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
import structlog
|
|
@@ -38,6 +39,9 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
|
38
39
|
# Generate or extract request ID
|
|
39
40
|
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
|
40
41
|
|
|
42
|
+
# Generate datetime for consistent logging across all layers
|
|
43
|
+
log_timestamp = datetime.now(UTC)
|
|
44
|
+
|
|
41
45
|
# Get DuckDB storage from app state if available
|
|
42
46
|
storage = getattr(request.app.state, "duckdb_storage", None)
|
|
43
47
|
|
|
@@ -45,6 +49,7 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
|
45
49
|
async with request_context(
|
|
46
50
|
request_id=request_id,
|
|
47
51
|
storage=storage,
|
|
52
|
+
log_timestamp=log_timestamp,
|
|
48
53
|
method=request.method,
|
|
49
54
|
path=str(request.url.path),
|
|
50
55
|
client_ip=request.client.host if request.client else "unknown",
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
"""Server header middleware to set a default server header for non-proxy routes."""
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
from fastapi import Request, Response
|
|
6
|
-
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
3
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
8
4
|
|
|
9
5
|
|
ccproxy/api/routes/__init__.py
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
# from ccproxy.api.routes.auth import router as auth_router # Module doesn't exist
|
|
4
4
|
from ccproxy.api.routes.claude import router as claude_router
|
|
5
5
|
from ccproxy.api.routes.health import router as health_router
|
|
6
|
-
from ccproxy.api.routes.metrics import
|
|
6
|
+
from ccproxy.api.routes.metrics import (
|
|
7
|
+
dashboard_router,
|
|
8
|
+
logs_router,
|
|
9
|
+
)
|
|
10
|
+
from ccproxy.api.routes.metrics import (
|
|
11
|
+
prometheus_router as metrics_router,
|
|
12
|
+
)
|
|
7
13
|
from ccproxy.api.routes.proxy import router as proxy_router
|
|
8
14
|
|
|
9
15
|
|
|
@@ -12,5 +18,7 @@ __all__ = [
|
|
|
12
18
|
"claude_router",
|
|
13
19
|
"health_router",
|
|
14
20
|
"metrics_router",
|
|
21
|
+
"logs_router",
|
|
22
|
+
"dashboard_router",
|
|
15
23
|
"proxy_router",
|
|
16
24
|
]
|
ccproxy/api/routes/claude.py
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from collections.abc import AsyncIterator
|
|
5
|
-
from typing import Any
|
|
6
5
|
|
|
7
6
|
import structlog
|
|
8
|
-
from fastapi import APIRouter,
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
9
8
|
from fastapi.responses import StreamingResponse
|
|
10
9
|
|
|
11
10
|
from ccproxy.adapters.openai.adapter import (
|
|
@@ -13,9 +12,8 @@ from ccproxy.adapters.openai.adapter import (
|
|
|
13
12
|
OpenAIChatCompletionRequest,
|
|
14
13
|
OpenAIChatCompletionResponse,
|
|
15
14
|
)
|
|
16
|
-
from ccproxy.api.dependencies import
|
|
15
|
+
from ccproxy.api.dependencies import ClaudeServiceDep
|
|
17
16
|
from ccproxy.models.messages import MessageCreateParams, MessageResponse
|
|
18
|
-
from ccproxy.services.claude_sdk_service import ClaudeSDKService
|
|
19
17
|
|
|
20
18
|
|
|
21
19
|
# Create the router for Claude SDK endpoints
|
|
@@ -28,7 +26,7 @@ logger = structlog.get_logger(__name__)
|
|
|
28
26
|
async def create_openai_chat_completion(
|
|
29
27
|
request: Request,
|
|
30
28
|
openai_request: OpenAIChatCompletionRequest,
|
|
31
|
-
claude_service:
|
|
29
|
+
claude_service: ClaudeServiceDep,
|
|
32
30
|
) -> StreamingResponse | OpenAIChatCompletionResponse:
|
|
33
31
|
"""Create a chat completion using Claude SDK with OpenAI-compatible format.
|
|
34
32
|
|
|
@@ -80,7 +78,13 @@ async def create_openai_chat_completion(
|
|
|
80
78
|
)
|
|
81
79
|
else:
|
|
82
80
|
# Convert non-streaming response to OpenAI format using adapter
|
|
83
|
-
|
|
81
|
+
# Convert MessageResponse model to dict for adapter
|
|
82
|
+
# In non-streaming mode, response should always be MessageResponse
|
|
83
|
+
assert isinstance(response, MessageResponse), (
|
|
84
|
+
"Non-streaming response must be MessageResponse"
|
|
85
|
+
)
|
|
86
|
+
response_dict = response.model_dump()
|
|
87
|
+
openai_response = adapter.adapt_response(response_dict)
|
|
84
88
|
return OpenAIChatCompletionResponse.model_validate(openai_response)
|
|
85
89
|
|
|
86
90
|
except Exception as e:
|
|
@@ -97,7 +101,7 @@ async def create_openai_chat_completion(
|
|
|
97
101
|
@router.post("/v1/messages", response_model=None)
|
|
98
102
|
async def create_anthropic_message(
|
|
99
103
|
request: MessageCreateParams,
|
|
100
|
-
claude_service:
|
|
104
|
+
claude_service: ClaudeServiceDep,
|
|
101
105
|
) -> StreamingResponse | MessageResponse:
|
|
102
106
|
"""Create a message using Claude SDK with Anthropic format.
|
|
103
107
|
|
|
@@ -127,9 +131,17 @@ async def create_anthropic_message(
|
|
|
127
131
|
async def anthropic_stream_generator() -> AsyncIterator[bytes]:
|
|
128
132
|
async for chunk in response: # type: ignore[union-attr]
|
|
129
133
|
if chunk:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
# All chunks from Claude SDK streaming should be dict format
|
|
135
|
+
# and need proper SSE event formatting
|
|
136
|
+
if isinstance(chunk, dict):
|
|
137
|
+
# Determine event type from chunk type
|
|
138
|
+
event_type = chunk.get("type", "message_delta")
|
|
139
|
+
yield f"event: {event_type}\n".encode()
|
|
140
|
+
yield f"data: {json.dumps(chunk)}\n\n".encode()
|
|
141
|
+
else:
|
|
142
|
+
# Fallback for unexpected format
|
|
143
|
+
yield f"data: {json.dumps(chunk)}\n\n".encode()
|
|
144
|
+
# No final [DONE] chunk for Anthropic format
|
|
133
145
|
|
|
134
146
|
return StreamingResponse(
|
|
135
147
|
anthropic_stream_generator(),
|
|
@@ -148,28 +160,7 @@ async def create_anthropic_message(
|
|
|
148
160
|
from ccproxy.core.errors import ClaudeProxyError
|
|
149
161
|
|
|
150
162
|
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
|
|
163
|
+
raise e
|
|
173
164
|
raise HTTPException(
|
|
174
165
|
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
175
166
|
) from e
|
ccproxy/api/routes/health.py
CHANGED
|
@@ -13,10 +13,12 @@ import asyncio
|
|
|
13
13
|
import functools
|
|
14
14
|
import shutil
|
|
15
15
|
import time
|
|
16
|
-
from datetime import UTC, datetime
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from enum import Enum
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
19
20
|
from fastapi import APIRouter, Response, status
|
|
21
|
+
from pydantic import BaseModel
|
|
20
22
|
from structlog import get_logger
|
|
21
23
|
|
|
22
24
|
from ccproxy import __version__
|
|
@@ -28,6 +30,28 @@ from ccproxy.services.credentials import CredentialsManager
|
|
|
28
30
|
router = APIRouter()
|
|
29
31
|
logger = get_logger(__name__)
|
|
30
32
|
|
|
33
|
+
|
|
34
|
+
class ClaudeCliStatus(str, Enum):
|
|
35
|
+
"""Claude CLI status enumeration."""
|
|
36
|
+
|
|
37
|
+
AVAILABLE = "available"
|
|
38
|
+
NOT_INSTALLED = "not_installed"
|
|
39
|
+
BINARY_FOUND_BUT_ERRORS = "binary_found_but_errors"
|
|
40
|
+
TIMEOUT = "timeout"
|
|
41
|
+
ERROR = "error"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClaudeCliInfo(BaseModel):
|
|
45
|
+
"""Claude CLI information with structured data."""
|
|
46
|
+
|
|
47
|
+
status: ClaudeCliStatus
|
|
48
|
+
version: str | None = None
|
|
49
|
+
binary_path: str | None = None
|
|
50
|
+
version_output: str | None = None
|
|
51
|
+
error: str | None = None
|
|
52
|
+
return_code: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
31
55
|
# Cache for Claude CLI check results
|
|
32
56
|
_claude_cli_cache: tuple[float, tuple[str, dict[str, Any]]] | None = None
|
|
33
57
|
_cache_ttl_seconds = 300 # Cache for 5 minutes
|
|
@@ -129,7 +153,7 @@ def _get_claude_cli_path() -> str | None:
|
|
|
129
153
|
return shutil.which("claude")
|
|
130
154
|
|
|
131
155
|
|
|
132
|
-
async def
|
|
156
|
+
async def check_claude_code() -> tuple[str, dict[str, Any]]:
|
|
133
157
|
"""Check Claude Code CLI installation and version by running 'claude --version'.
|
|
134
158
|
|
|
135
159
|
Results are cached for 5 minutes to avoid repeated subprocess calls.
|
|
@@ -259,6 +283,36 @@ async def _check_claude_code() -> tuple[str, dict[str, Any]]:
|
|
|
259
283
|
return result
|
|
260
284
|
|
|
261
285
|
|
|
286
|
+
async def get_claude_cli_info() -> ClaudeCliInfo:
|
|
287
|
+
"""Get Claude CLI information as a structured Pydantic model.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
ClaudeCliInfo: Structured information about Claude CLI installation and status
|
|
291
|
+
"""
|
|
292
|
+
cli_status, cli_details = await check_claude_code()
|
|
293
|
+
|
|
294
|
+
# Map the status to our enum values
|
|
295
|
+
if cli_status == "pass":
|
|
296
|
+
status_value = ClaudeCliStatus.AVAILABLE
|
|
297
|
+
elif cli_details.get("cli_status") == "not_installed":
|
|
298
|
+
status_value = ClaudeCliStatus.NOT_INSTALLED
|
|
299
|
+
elif cli_details.get("cli_status") == "binary_found_but_errors":
|
|
300
|
+
status_value = ClaudeCliStatus.BINARY_FOUND_BUT_ERRORS
|
|
301
|
+
elif cli_details.get("cli_status") == "timeout":
|
|
302
|
+
status_value = ClaudeCliStatus.TIMEOUT
|
|
303
|
+
else:
|
|
304
|
+
status_value = ClaudeCliStatus.ERROR
|
|
305
|
+
|
|
306
|
+
return ClaudeCliInfo(
|
|
307
|
+
status=status_value,
|
|
308
|
+
version=cli_details.get("version"),
|
|
309
|
+
binary_path=cli_details.get("binary_path"),
|
|
310
|
+
version_output=cli_details.get("version_output"),
|
|
311
|
+
error=cli_details.get("error"),
|
|
312
|
+
return_code=cli_details.get("return_code"),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
262
316
|
async def _check_claude_sdk() -> tuple[str, dict[str, Any]]:
|
|
263
317
|
"""Check Claude SDK installation and version.
|
|
264
318
|
|
|
@@ -337,7 +391,7 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
|
|
|
337
391
|
|
|
338
392
|
# Check OAuth credentials, CLI, and SDK separately
|
|
339
393
|
oauth_status, oauth_details = await _check_oauth2_credentials()
|
|
340
|
-
cli_status, cli_details = await
|
|
394
|
+
cli_status, cli_details = await check_claude_code()
|
|
341
395
|
sdk_status, sdk_details = await _check_claude_sdk()
|
|
342
396
|
|
|
343
397
|
# Service is ready if no check returns "fail"
|
|
@@ -424,7 +478,7 @@ async def detailed_health_check(response: Response) -> dict[str, Any]:
|
|
|
424
478
|
|
|
425
479
|
# Perform all health checks
|
|
426
480
|
oauth_status, oauth_details = await _check_oauth2_credentials()
|
|
427
|
-
cli_status, cli_details = await
|
|
481
|
+
cli_status, cli_details = await check_claude_code()
|
|
428
482
|
sdk_status, sdk_details = await _check_claude_sdk()
|
|
429
483
|
|
|
430
484
|
# Determine overall status - prioritize failures, then warnings
|