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,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"}
|
ccproxy/auth/__init__.py
ADDED
|
@@ -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)]
|