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,238 @@
1
+ """Proxy endpoints for CCProxy API Server."""
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException, Request, Response
8
+ from fastapi.responses import StreamingResponse
9
+ from starlette.background import BackgroundTask
10
+
11
+ from ccproxy.adapters.openai.adapter import OpenAIAdapter
12
+ from ccproxy.api.dependencies import ProxyServiceDep
13
+ from ccproxy.api.responses import ProxyResponse
14
+ from ccproxy.core.errors import ProxyHTTPException
15
+
16
+
17
+ # Create the router for proxy endpoints
18
+ router = APIRouter(tags=["proxy"])
19
+
20
+
21
+ @router.post("/v1/chat/completions", response_model=None)
22
+ async def create_openai_chat_completion(
23
+ request: Request,
24
+ proxy_service: ProxyServiceDep,
25
+ ) -> StreamingResponse | Response:
26
+ """Create a chat completion using Claude AI with OpenAI-compatible format.
27
+
28
+ This endpoint handles OpenAI API format requests and forwards them
29
+ directly to Claude via the proxy service.
30
+ """
31
+ try:
32
+ # Get request body
33
+ body = await request.body()
34
+
35
+ # Get headers and query params
36
+ headers = dict(request.headers)
37
+ query_params: dict[str, str | list[str]] | None = (
38
+ dict(request.query_params) if request.query_params else None
39
+ )
40
+
41
+ # Handle the request using proxy service directly
42
+ response = await proxy_service.handle_request(
43
+ method=request.method,
44
+ path=request.url.path,
45
+ headers=headers,
46
+ body=body,
47
+ query_params=query_params,
48
+ request=request, # Pass the request object for context access
49
+ )
50
+
51
+ # Return appropriate response type
52
+ if isinstance(response, StreamingResponse):
53
+ # Already a streaming response
54
+ return response
55
+ else:
56
+ # Tuple response - handle regular response
57
+ status_code, response_headers, response_body = response
58
+ if status_code >= 400:
59
+ # Forward error response directly with headers
60
+ return ProxyResponse(
61
+ content=response_body,
62
+ status_code=status_code,
63
+ headers=response_headers,
64
+ media_type=response_headers.get("content-type", "application/json"),
65
+ )
66
+
67
+ # Check if this is a streaming response based on content-type
68
+ content_type = response_headers.get("content-type", "")
69
+ if "text/event-stream" in content_type:
70
+ # Return as streaming response
71
+ async def stream_generator() -> AsyncIterator[bytes]:
72
+ # Split the SSE data into chunks
73
+ for line in response_body.decode().split("\n"):
74
+ if line.strip():
75
+ yield f"{line}\n".encode()
76
+
77
+ return StreamingResponse(
78
+ stream_generator(),
79
+ media_type="text/event-stream",
80
+ headers={
81
+ "Cache-Control": "no-cache",
82
+ "Connection": "keep-alive",
83
+ },
84
+ )
85
+ else:
86
+ # Parse JSON response
87
+ response_data = json.loads(response_body.decode())
88
+
89
+ # Convert Anthropic response back to OpenAI format for /chat/completions
90
+ openai_adapter = OpenAIAdapter()
91
+ openai_response = openai_adapter.adapt_response(response_data)
92
+
93
+ # Return response with headers
94
+ return ProxyResponse(
95
+ content=json.dumps(openai_response),
96
+ status_code=status_code,
97
+ headers=response_headers,
98
+ media_type=response_headers.get("content-type", "application/json"),
99
+ )
100
+
101
+ except Exception as e:
102
+ raise HTTPException(
103
+ status_code=500, detail=f"Internal server error: {str(e)}"
104
+ ) from e
105
+
106
+
107
+ @router.post("/v1/messages", response_model=None)
108
+ async def create_anthropic_message(
109
+ request: Request,
110
+ proxy_service: ProxyServiceDep,
111
+ ) -> StreamingResponse | Response:
112
+ """Create a message using Claude AI with Anthropic format.
113
+
114
+ This endpoint handles Anthropic API format requests and forwards them
115
+ directly to Claude via the proxy service.
116
+ """
117
+ try:
118
+ # Get request body
119
+ body = await request.body()
120
+
121
+ # Get headers and query params
122
+ headers = dict(request.headers)
123
+ query_params: dict[str, str | list[str]] | None = (
124
+ dict(request.query_params) if request.query_params else None
125
+ )
126
+
127
+ # Handle the request using proxy service directly
128
+ response = await proxy_service.handle_request(
129
+ method=request.method,
130
+ path=request.url.path,
131
+ headers=headers,
132
+ body=body,
133
+ query_params=query_params,
134
+ request=request, # Pass the request object for context access
135
+ )
136
+
137
+ # Return appropriate response type
138
+ if isinstance(response, StreamingResponse):
139
+ # Already a streaming response
140
+ return response
141
+ else:
142
+ # Tuple response - handle regular response
143
+ status_code, response_headers, response_body = response
144
+ if status_code >= 400:
145
+ # Forward error response directly with headers
146
+ return ProxyResponse(
147
+ content=response_body,
148
+ status_code=status_code,
149
+ headers=response_headers,
150
+ media_type=response_headers.get("content-type", "application/json"),
151
+ )
152
+
153
+ # Check if this is a streaming response based on content-type
154
+ content_type = response_headers.get("content-type", "")
155
+ if "text/event-stream" in content_type:
156
+ # Return as streaming response
157
+ async def stream_generator() -> AsyncIterator[bytes]:
158
+ # Split the SSE data into chunks
159
+ for line in response_body.decode().split("\n"):
160
+ if line.strip():
161
+ yield f"{line}\n".encode()
162
+
163
+ return StreamingResponse(
164
+ stream_generator(),
165
+ media_type="text/event-stream",
166
+ headers={
167
+ "Cache-Control": "no-cache",
168
+ "Connection": "keep-alive",
169
+ },
170
+ )
171
+ else:
172
+ # Parse JSON response
173
+ response_data = json.loads(response_body.decode())
174
+
175
+ # Return response with headers
176
+ return ProxyResponse(
177
+ content=response_body, # Use original body to preserve exact format
178
+ status_code=status_code,
179
+ headers=response_headers,
180
+ media_type=response_headers.get("content-type", "application/json"),
181
+ )
182
+
183
+ except Exception as e:
184
+ raise HTTPException(
185
+ status_code=500, detail=f"Internal server error: {str(e)}"
186
+ ) from e
187
+
188
+
189
+ @router.get("/v1/models", response_model=None)
190
+ async def list_models(
191
+ request: Request,
192
+ proxy_service: ProxyServiceDep,
193
+ ) -> Response:
194
+ """List available models using the proxy service.
195
+
196
+ Returns a combined list of Anthropic models and recent OpenAI models.
197
+ """
198
+ try:
199
+ # Get headers
200
+ headers = dict(request.headers)
201
+
202
+ # Handle the request using proxy service
203
+ response = await proxy_service.handle_request(
204
+ method="GET",
205
+ path="/v1/models",
206
+ headers=headers,
207
+ body=None,
208
+ request=request,
209
+ )
210
+
211
+ # Since /v1/models never streams, we know it returns a tuple
212
+ if isinstance(response, tuple):
213
+ status_code, response_headers, response_body = response
214
+ else:
215
+ # This shouldn't happen for /v1/models, but handle it gracefully
216
+ raise HTTPException(
217
+ status_code=500,
218
+ detail="Unexpected streaming response for /v1/models endpoint",
219
+ )
220
+
221
+ # Return response with headers
222
+ return ProxyResponse(
223
+ content=response_body,
224
+ status_code=status_code,
225
+ headers=response_headers,
226
+ media_type=response_headers.get("content-type", "application/json"),
227
+ )
228
+
229
+ except Exception as e:
230
+ raise HTTPException(
231
+ status_code=500, detail=f"Internal server error: {str(e)}"
232
+ ) from e
233
+
234
+
235
+ @router.get("/status")
236
+ async def proxy_status() -> dict[str, str]:
237
+ """Get proxy status."""
238
+ return {"status": "proxy API available", "version": "1.0.0"}
@@ -0,0 +1,75 @@
1
+ """Authentication module for centralized auth handling."""
2
+
3
+ from ccproxy.auth.bearer import BearerTokenAuthManager
4
+ from ccproxy.auth.credentials_adapter import CredentialsAuthManager
5
+ from ccproxy.auth.dependencies import (
6
+ AccessTokenDep,
7
+ AuthManagerDep,
8
+ RequiredAuthDep,
9
+ get_access_token,
10
+ get_auth_manager,
11
+ get_bearer_auth_manager,
12
+ get_credentials_auth_manager,
13
+ require_auth,
14
+ )
15
+ from ccproxy.auth.exceptions import (
16
+ AuthenticationError,
17
+ AuthenticationRequiredError,
18
+ CredentialsError,
19
+ CredentialsExpiredError,
20
+ CredentialsInvalidError,
21
+ CredentialsNotFoundError,
22
+ CredentialsStorageError,
23
+ InsufficientPermissionsError,
24
+ InvalidTokenError,
25
+ OAuthCallbackError,
26
+ OAuthError,
27
+ OAuthLoginError,
28
+ OAuthTokenRefreshError,
29
+ )
30
+ from ccproxy.auth.manager import AuthManager, BaseAuthManager
31
+ from ccproxy.auth.storage import (
32
+ JsonFileTokenStorage,
33
+ KeyringTokenStorage,
34
+ TokenStorage,
35
+ )
36
+ from ccproxy.services.credentials.manager import CredentialsManager
37
+
38
+
39
+ __all__ = [
40
+ # Manager interfaces
41
+ "AuthManager",
42
+ "BaseAuthManager",
43
+ # Implementations
44
+ "BearerTokenAuthManager",
45
+ "CredentialsAuthManager",
46
+ "CredentialsManager",
47
+ # Storage interfaces and implementations
48
+ "TokenStorage",
49
+ "JsonFileTokenStorage",
50
+ "KeyringTokenStorage",
51
+ # Exceptions
52
+ "AuthenticationError",
53
+ "AuthenticationRequiredError",
54
+ "CredentialsError",
55
+ "CredentialsExpiredError",
56
+ "CredentialsInvalidError",
57
+ "CredentialsNotFoundError",
58
+ "CredentialsStorageError",
59
+ "InvalidTokenError",
60
+ "InsufficientPermissionsError",
61
+ "OAuthCallbackError",
62
+ "OAuthError",
63
+ "OAuthLoginError",
64
+ "OAuthTokenRefreshError",
65
+ # Dependencies
66
+ "get_auth_manager",
67
+ "get_bearer_auth_manager",
68
+ "get_credentials_auth_manager",
69
+ "require_auth",
70
+ "get_access_token",
71
+ # Type aliases
72
+ "AuthManagerDep",
73
+ "RequiredAuthDep",
74
+ "AccessTokenDep",
75
+ ]
ccproxy/auth/bearer.py ADDED
@@ -0,0 +1,68 @@
1
+ """Bearer token authentication implementation."""
2
+
3
+ from typing import Any
4
+
5
+ from ccproxy.auth.exceptions import AuthenticationError
6
+ from ccproxy.auth.manager import BaseAuthManager
7
+ from ccproxy.auth.models import ClaudeCredentials, UserProfile
8
+
9
+
10
+ class BearerTokenAuthManager(BaseAuthManager):
11
+ """Authentication manager for static bearer tokens."""
12
+
13
+ def __init__(self, token: str) -> None:
14
+ """Initialize with a static bearer token.
15
+
16
+ Args:
17
+ token: Bearer token string
18
+ """
19
+ self.token = token.strip()
20
+ if not self.token:
21
+ raise ValueError("Token cannot be empty")
22
+
23
+ async def get_access_token(self) -> str:
24
+ """Get the bearer token.
25
+
26
+ Returns:
27
+ Bearer token string
28
+
29
+ Raises:
30
+ AuthenticationError: If token is invalid
31
+ """
32
+ if not self.token:
33
+ raise AuthenticationError("No bearer token available")
34
+ return self.token
35
+
36
+ async def get_credentials(self) -> ClaudeCredentials:
37
+ """Get credentials (not supported for bearer tokens).
38
+
39
+ Raises:
40
+ AuthenticationError: Bearer tokens don't support full credentials
41
+ """
42
+ raise AuthenticationError(
43
+ "Bearer token authentication doesn't support full credentials"
44
+ )
45
+
46
+ async def is_authenticated(self) -> bool:
47
+ """Check if bearer token is available.
48
+
49
+ Returns:
50
+ True if token is available, False otherwise
51
+ """
52
+ return bool(self.token)
53
+
54
+ async def get_user_profile(self) -> UserProfile | None:
55
+ """Get user profile (not supported for bearer tokens).
56
+
57
+ Returns:
58
+ None - bearer tokens don't support user profiles
59
+ """
60
+ return None
61
+
62
+ async def __aenter__(self) -> "BearerTokenAuthManager":
63
+ """Async context manager entry."""
64
+ return self
65
+
66
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
67
+ """Async context manager exit."""
68
+ pass
@@ -0,0 +1,93 @@
1
+ """Adapter to make CredentialsManager compatible with AuthManager interface."""
2
+
3
+ from typing import Any
4
+
5
+ from ccproxy.auth.exceptions import (
6
+ AuthenticationError,
7
+ CredentialsError,
8
+ CredentialsExpiredError,
9
+ CredentialsNotFoundError,
10
+ )
11
+ from ccproxy.auth.manager import BaseAuthManager
12
+ from ccproxy.auth.models import ClaudeCredentials, UserProfile
13
+ from ccproxy.services.credentials.manager import CredentialsManager
14
+
15
+
16
+ class CredentialsAuthManager(BaseAuthManager):
17
+ """Adapter to make CredentialsManager compatible with AuthManager interface."""
18
+
19
+ def __init__(self, credentials_manager: CredentialsManager | None = None) -> None:
20
+ """Initialize with credentials manager.
21
+
22
+ Args:
23
+ credentials_manager: CredentialsManager instance, creates new if None
24
+ """
25
+ self._credentials_manager = credentials_manager or CredentialsManager()
26
+
27
+ async def get_access_token(self) -> str:
28
+ """Get valid access token from credentials manager.
29
+
30
+ Returns:
31
+ Access token string
32
+
33
+ Raises:
34
+ AuthenticationError: If authentication fails
35
+ """
36
+ try:
37
+ return await self._credentials_manager.get_access_token()
38
+ except CredentialsNotFoundError as e:
39
+ raise AuthenticationError("No credentials found") from e
40
+ except CredentialsExpiredError as e:
41
+ raise AuthenticationError("Credentials expired") from e
42
+ except CredentialsError as e:
43
+ raise AuthenticationError(f"Credentials error: {e}") from e
44
+
45
+ async def get_credentials(self) -> ClaudeCredentials:
46
+ """Get valid credentials from credentials manager.
47
+
48
+ Returns:
49
+ Valid credentials
50
+
51
+ Raises:
52
+ AuthenticationError: If authentication fails
53
+ """
54
+ try:
55
+ return await self._credentials_manager.get_valid_credentials()
56
+ except CredentialsNotFoundError as e:
57
+ raise AuthenticationError("No credentials found") from e
58
+ except CredentialsExpiredError as e:
59
+ raise AuthenticationError("Credentials expired") from e
60
+ except CredentialsError as e:
61
+ raise AuthenticationError(f"Credentials error: {e}") from e
62
+
63
+ async def is_authenticated(self) -> bool:
64
+ """Check if current authentication is valid.
65
+
66
+ Returns:
67
+ True if authenticated, False otherwise
68
+ """
69
+ try:
70
+ await self._credentials_manager.get_valid_credentials()
71
+ return True
72
+ except CredentialsError:
73
+ return False
74
+
75
+ async def get_user_profile(self) -> UserProfile | None:
76
+ """Get user profile information.
77
+
78
+ Returns:
79
+ UserProfile if available, None otherwise
80
+ """
81
+ try:
82
+ return await self._credentials_manager.fetch_user_profile()
83
+ except CredentialsError:
84
+ return None
85
+
86
+ async def __aenter__(self) -> "CredentialsAuthManager":
87
+ """Async context manager entry."""
88
+ await self._credentials_manager.__aenter__()
89
+ return self
90
+
91
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
92
+ """Async context manager exit."""
93
+ await self._credentials_manager.__aexit__(exc_type, exc_val, exc_tb)
@@ -0,0 +1,229 @@
1
+ """FastAPI dependency injection for authentication."""
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Any
4
+
5
+ from fastapi import Depends, HTTPException, status
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+
8
+
9
+ if TYPE_CHECKING:
10
+ from ccproxy.config.settings import Settings
11
+
12
+ from ccproxy.auth.bearer import BearerTokenAuthManager
13
+ from ccproxy.auth.credentials_adapter import CredentialsAuthManager
14
+ from ccproxy.auth.exceptions import AuthenticationError, AuthenticationRequiredError
15
+ from ccproxy.auth.manager import AuthManager
16
+
17
+
18
+ # FastAPI security scheme for bearer tokens
19
+ bearer_scheme = HTTPBearer(auto_error=False)
20
+
21
+
22
+ async def get_credentials_auth_manager() -> AuthManager:
23
+ """Get credentials-based authentication manager.
24
+
25
+ Returns:
26
+ CredentialsAuthManager instance
27
+ """
28
+ return CredentialsAuthManager()
29
+
30
+
31
+ async def get_bearer_auth_manager(
32
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
33
+ ) -> AuthManager:
34
+ """Get bearer token authentication manager.
35
+
36
+ Args:
37
+ credentials: HTTP authorization credentials
38
+
39
+ Returns:
40
+ BearerTokenAuthManager instance
41
+
42
+ Raises:
43
+ HTTPException: If no valid bearer token provided
44
+ """
45
+ if not credentials or not credentials.credentials:
46
+ raise HTTPException(
47
+ status_code=status.HTTP_401_UNAUTHORIZED,
48
+ detail="Bearer token required",
49
+ headers={"WWW-Authenticate": "Bearer"},
50
+ )
51
+
52
+ return BearerTokenAuthManager(credentials.credentials)
53
+
54
+
55
+ async def _get_auth_manager_with_settings(
56
+ credentials: HTTPAuthorizationCredentials | None,
57
+ settings: "Settings",
58
+ ) -> AuthManager:
59
+ """Internal function to get auth manager with specific settings.
60
+
61
+ Args:
62
+ credentials: HTTP authorization credentials
63
+ settings: Application settings
64
+
65
+ Returns:
66
+ AuthManager instance
67
+
68
+ Raises:
69
+ HTTPException: If no valid authentication available
70
+ """
71
+ # Try bearer token first if provided
72
+ if credentials and credentials.credentials:
73
+ try:
74
+ # If API has configured auth_token, validate against it
75
+ if settings.security.auth_token:
76
+ if credentials.credentials == settings.security.auth_token:
77
+ bearer_auth = BearerTokenAuthManager(credentials.credentials)
78
+ if await bearer_auth.is_authenticated():
79
+ return bearer_auth
80
+ else:
81
+ # Token doesn't match configured auth_token
82
+ raise HTTPException(
83
+ status_code=status.HTTP_401_UNAUTHORIZED,
84
+ detail="Invalid bearer token",
85
+ headers={"WWW-Authenticate": "Bearer"},
86
+ )
87
+ else:
88
+ # No auth_token configured, accept any bearer token
89
+ bearer_auth = BearerTokenAuthManager(credentials.credentials)
90
+ if await bearer_auth.is_authenticated():
91
+ return bearer_auth
92
+ except (AuthenticationError, ValueError):
93
+ pass
94
+
95
+ # Fall back to credentials only if no auth_token is configured
96
+ if not settings.security.auth_token:
97
+ try:
98
+ credentials_auth = CredentialsAuthManager()
99
+ if await credentials_auth.is_authenticated():
100
+ return credentials_auth
101
+ except AuthenticationError:
102
+ pass
103
+
104
+ raise HTTPException(
105
+ status_code=status.HTTP_401_UNAUTHORIZED,
106
+ detail="Authentication required",
107
+ headers={"WWW-Authenticate": "Bearer"},
108
+ )
109
+
110
+
111
+ async def get_auth_manager(
112
+ credentials: Annotated[
113
+ HTTPAuthorizationCredentials | None, Depends(bearer_scheme)
114
+ ] = None,
115
+ ) -> AuthManager:
116
+ """Get authentication manager with fallback strategy.
117
+
118
+ Try bearer token first, then fall back to credentials.
119
+
120
+ Args:
121
+ credentials: HTTP authorization credentials
122
+
123
+ Returns:
124
+ AuthManager instance
125
+
126
+ Raises:
127
+ HTTPException: If no valid authentication available
128
+ """
129
+ # Import here to avoid circular imports
130
+ from ccproxy.config.settings import get_settings
131
+
132
+ settings = get_settings()
133
+ return await _get_auth_manager_with_settings(credentials, settings)
134
+
135
+
136
+ async def get_auth_manager_with_injected_settings(
137
+ credentials: Annotated[
138
+ HTTPAuthorizationCredentials | None, Depends(bearer_scheme)
139
+ ] = None,
140
+ ) -> AuthManager:
141
+ """Get authentication manager with dependency-injected settings.
142
+
143
+ This version uses FastAPI's dependency injection for settings,
144
+ which allows test overrides to work properly.
145
+
146
+ Args:
147
+ credentials: HTTP authorization credentials
148
+ settings: Application settings (injected by FastAPI)
149
+
150
+ Returns:
151
+ AuthManager instance
152
+
153
+ Raises:
154
+ HTTPException: If no valid authentication available
155
+ """
156
+ # Import here to avoid circular imports
157
+ from ccproxy.config.settings import get_settings
158
+
159
+ settings = get_settings()
160
+ return await _get_auth_manager_with_settings(credentials, settings)
161
+
162
+
163
+ async def require_auth(
164
+ auth_manager: Annotated[AuthManager, Depends(get_auth_manager)],
165
+ ) -> AuthManager:
166
+ """Require authentication for endpoint.
167
+
168
+ Args:
169
+ auth_manager: Authentication manager
170
+
171
+ Returns:
172
+ AuthManager instance
173
+
174
+ Raises:
175
+ HTTPException: If authentication fails
176
+ """
177
+ try:
178
+ if not await auth_manager.is_authenticated():
179
+ raise AuthenticationRequiredError("Authentication required")
180
+ return auth_manager
181
+ except AuthenticationError as e:
182
+ raise HTTPException(
183
+ status_code=status.HTTP_401_UNAUTHORIZED,
184
+ detail=str(e),
185
+ headers={"WWW-Authenticate": "Bearer"},
186
+ ) from e
187
+
188
+
189
+ async def get_access_token(
190
+ auth_manager: Annotated[AuthManager, Depends(require_auth)],
191
+ ) -> str:
192
+ """Get access token from authenticated manager.
193
+
194
+ Args:
195
+ auth_manager: Authentication manager
196
+
197
+ Returns:
198
+ Access token string
199
+
200
+ Raises:
201
+ HTTPException: If token retrieval fails
202
+ """
203
+ try:
204
+ return await auth_manager.get_access_token()
205
+ except AuthenticationError as e:
206
+ raise HTTPException(
207
+ status_code=status.HTTP_401_UNAUTHORIZED,
208
+ detail=str(e),
209
+ headers={"WWW-Authenticate": "Bearer"},
210
+ ) from e
211
+
212
+
213
+ async def get_auth_manager_dependency(
214
+ credentials: Annotated[
215
+ HTTPAuthorizationCredentials | None, Depends(bearer_scheme)
216
+ ] = None,
217
+ ) -> AuthManager:
218
+ """Dependency wrapper for getting auth manager with settings injection."""
219
+ # Import here to avoid circular imports
220
+ from ccproxy.config.settings import get_settings
221
+
222
+ settings = get_settings()
223
+ return await _get_auth_manager_with_settings(credentials, settings)
224
+
225
+
226
+ # Type aliases for common dependencies
227
+ AuthManagerDep = Annotated[AuthManager, Depends(get_auth_manager)]
228
+ RequiredAuthDep = Annotated[AuthManager, Depends(require_auth)]
229
+ AccessTokenDep = Annotated[str, Depends(get_access_token)]