ccproxy-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)
@@ -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"}