ccproxy-api 0.1.0__py3-none-any.whl → 0.1.1__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/api/app.py +70 -0
- ccproxy/api/routes/claude.py +0 -6
- ccproxy/api/routes/proxy.py +13 -6
- ccproxy/auth/conditional.py +84 -0
- ccproxy/cli/commands/auth.py +59 -43
- ccproxy/cli/commands/config/commands.py +2 -2
- ccproxy/cli/commands/serve.py +304 -86
- ccproxy/cli/main.py +32 -110
- ccproxy/cli/options/claude_options.py +1 -115
- ccproxy/cli/options/core_options.py +1 -13
- ccproxy/cli/options/security_options.py +1 -9
- ccproxy/cli/options/server_options.py +1 -52
- ccproxy/config/auth.py +2 -2
- ccproxy/config/docker_settings.py +1 -1
- ccproxy/config/pricing.py +5 -6
- ccproxy/config/scheduler.py +5 -6
- ccproxy/config/settings.py +0 -2
- ccproxy/core/async_utils.py +1 -1
- ccproxy/core/types.py +3 -9
- ccproxy/models/messages.py +1 -2
- ccproxy/models/responses.py +0 -36
- ccproxy/pricing/models.py +5 -6
- ccproxy/services/proxy_service.py +6 -7
- {ccproxy_api-0.1.0.dist-info → ccproxy_api-0.1.1.dist-info}/METADATA +62 -7
- {ccproxy_api-0.1.0.dist-info → ccproxy_api-0.1.1.dist-info}/RECORD +29 -28
- {ccproxy_api-0.1.0.dist-info → ccproxy_api-0.1.1.dist-info}/entry_points.txt +1 -0
- {ccproxy_api-0.1.0.dist-info → ccproxy_api-0.1.1.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.0.dist-info → ccproxy_api-0.1.1.dist-info}/licenses/LICENSE +0 -0
ccproxy/_version.py
CHANGED
ccproxy/api/app.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import AsyncGenerator
|
|
4
4
|
from contextlib import asynccontextmanager
|
|
5
|
+
from datetime import UTC, datetime
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from fastapi import FastAPI, HTTPException
|
|
@@ -23,11 +24,13 @@ from ccproxy.api.routes.metrics import (
|
|
|
23
24
|
prometheus_router,
|
|
24
25
|
)
|
|
25
26
|
from ccproxy.api.routes.proxy import router as proxy_router
|
|
27
|
+
from ccproxy.auth.exceptions import CredentialsNotFoundError
|
|
26
28
|
from ccproxy.auth.oauth.routes import router as oauth_router
|
|
27
29
|
from ccproxy.config.settings import Settings, get_settings
|
|
28
30
|
from ccproxy.core.logging import setup_logging
|
|
29
31
|
from ccproxy.observability.storage.duckdb_simple import SimpleDuckDBStorage
|
|
30
32
|
from ccproxy.scheduler.manager import start_scheduler, stop_scheduler
|
|
33
|
+
from ccproxy.services.credentials import CredentialsManager
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
logger = get_logger(__name__)
|
|
@@ -58,6 +61,73 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
|
58
61
|
"claude_cli_search_paths", paths=settings.claude.get_searched_paths()
|
|
59
62
|
)
|
|
60
63
|
|
|
64
|
+
# Validate authentication token at startup
|
|
65
|
+
try:
|
|
66
|
+
credentials_manager = CredentialsManager()
|
|
67
|
+
validation = await credentials_manager.validate()
|
|
68
|
+
|
|
69
|
+
if validation.valid and not validation.expired:
|
|
70
|
+
credentials = validation.credentials
|
|
71
|
+
oauth_token = credentials.claude_ai_oauth if credentials else None
|
|
72
|
+
|
|
73
|
+
if oauth_token and oauth_token.expires_at_datetime:
|
|
74
|
+
hours_until_expiry = int(
|
|
75
|
+
(
|
|
76
|
+
oauth_token.expires_at_datetime - datetime.now(UTC)
|
|
77
|
+
).total_seconds()
|
|
78
|
+
/ 3600
|
|
79
|
+
)
|
|
80
|
+
logger.info(
|
|
81
|
+
"auth_token_valid",
|
|
82
|
+
expires_in_hours=hours_until_expiry,
|
|
83
|
+
subscription_type=oauth_token.subscription_type,
|
|
84
|
+
credentials_path=str(validation.path) if validation.path else None,
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
logger.info("auth_token_valid", credentials_path=str(validation.path))
|
|
88
|
+
elif validation.expired:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"auth_token_expired",
|
|
91
|
+
message="Authentication token has expired. Please run 'ccproxy auth login' to refresh.",
|
|
92
|
+
credentials_path=str(validation.path) if validation.path else None,
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
logger.warning(
|
|
96
|
+
"auth_token_invalid",
|
|
97
|
+
message="Authentication token is invalid. Please run 'ccproxy auth login'.",
|
|
98
|
+
credentials_path=str(validation.path) if validation.path else None,
|
|
99
|
+
)
|
|
100
|
+
except CredentialsNotFoundError:
|
|
101
|
+
logger.warning(
|
|
102
|
+
"auth_token_not_found",
|
|
103
|
+
message="No authentication credentials found. Please run 'ccproxy auth login' to authenticate.",
|
|
104
|
+
searched_paths=settings.auth.storage.storage_paths,
|
|
105
|
+
)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(
|
|
108
|
+
"auth_token_validation_error",
|
|
109
|
+
error=str(e),
|
|
110
|
+
message="Failed to validate authentication token. The server will continue without authentication.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Validate Claude binary at startup
|
|
114
|
+
claude_path, found_in_path = settings.claude.find_claude_cli()
|
|
115
|
+
if claude_path:
|
|
116
|
+
logger.info(
|
|
117
|
+
"claude_binary_found",
|
|
118
|
+
path=claude_path,
|
|
119
|
+
found_in_path=found_in_path,
|
|
120
|
+
message=f"Claude CLI binary found at: {claude_path}",
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
searched_paths = settings.claude.get_searched_paths()
|
|
124
|
+
logger.warning(
|
|
125
|
+
"claude_binary_not_found",
|
|
126
|
+
message="Claude CLI binary not found. Please install Claude CLI to use SDK features.",
|
|
127
|
+
searched_paths=searched_paths,
|
|
128
|
+
install_command="npm install -g @anthropic-ai/claude-code",
|
|
129
|
+
)
|
|
130
|
+
|
|
61
131
|
# Start scheduler system
|
|
62
132
|
try:
|
|
63
133
|
scheduler = await start_scheduler(settings)
|
ccproxy/api/routes/claude.py
CHANGED
|
@@ -173,9 +173,3 @@ async def list_models(
|
|
|
173
173
|
raise HTTPException(
|
|
174
174
|
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
175
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"}
|
ccproxy/api/routes/proxy.py
CHANGED
|
@@ -11,6 +11,7 @@ from starlette.background import BackgroundTask
|
|
|
11
11
|
from ccproxy.adapters.openai.adapter import OpenAIAdapter
|
|
12
12
|
from ccproxy.api.dependencies import ProxyServiceDep
|
|
13
13
|
from ccproxy.api.responses import ProxyResponse
|
|
14
|
+
from ccproxy.auth.conditional import ConditionalAuthDep
|
|
14
15
|
from ccproxy.core.errors import ProxyHTTPException
|
|
15
16
|
|
|
16
17
|
|
|
@@ -22,6 +23,7 @@ router = APIRouter(tags=["proxy"])
|
|
|
22
23
|
async def create_openai_chat_completion(
|
|
23
24
|
request: Request,
|
|
24
25
|
proxy_service: ProxyServiceDep,
|
|
26
|
+
auth: ConditionalAuthDep,
|
|
25
27
|
) -> StreamingResponse | Response:
|
|
26
28
|
"""Create a chat completion using Claude AI with OpenAI-compatible format.
|
|
27
29
|
|
|
@@ -98,6 +100,9 @@ async def create_openai_chat_completion(
|
|
|
98
100
|
media_type=response_headers.get("content-type", "application/json"),
|
|
99
101
|
)
|
|
100
102
|
|
|
103
|
+
except HTTPException:
|
|
104
|
+
# Re-raise HTTPException as-is (including 401 auth errors)
|
|
105
|
+
raise
|
|
101
106
|
except Exception as e:
|
|
102
107
|
raise HTTPException(
|
|
103
108
|
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
@@ -108,6 +113,7 @@ async def create_openai_chat_completion(
|
|
|
108
113
|
async def create_anthropic_message(
|
|
109
114
|
request: Request,
|
|
110
115
|
proxy_service: ProxyServiceDep,
|
|
116
|
+
auth: ConditionalAuthDep,
|
|
111
117
|
) -> StreamingResponse | Response:
|
|
112
118
|
"""Create a message using Claude AI with Anthropic format.
|
|
113
119
|
|
|
@@ -180,6 +186,9 @@ async def create_anthropic_message(
|
|
|
180
186
|
media_type=response_headers.get("content-type", "application/json"),
|
|
181
187
|
)
|
|
182
188
|
|
|
189
|
+
except HTTPException:
|
|
190
|
+
# Re-raise HTTPException as-is (including 401 auth errors)
|
|
191
|
+
raise
|
|
183
192
|
except Exception as e:
|
|
184
193
|
raise HTTPException(
|
|
185
194
|
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
@@ -190,6 +199,7 @@ async def create_anthropic_message(
|
|
|
190
199
|
async def list_models(
|
|
191
200
|
request: Request,
|
|
192
201
|
proxy_service: ProxyServiceDep,
|
|
202
|
+
auth: ConditionalAuthDep,
|
|
193
203
|
) -> Response:
|
|
194
204
|
"""List available models using the proxy service.
|
|
195
205
|
|
|
@@ -226,13 +236,10 @@ async def list_models(
|
|
|
226
236
|
media_type=response_headers.get("content-type", "application/json"),
|
|
227
237
|
)
|
|
228
238
|
|
|
239
|
+
except HTTPException:
|
|
240
|
+
# Re-raise HTTPException as-is (including 401 auth errors)
|
|
241
|
+
raise
|
|
229
242
|
except Exception as e:
|
|
230
243
|
raise HTTPException(
|
|
231
244
|
status_code=500, detail=f"Internal server error: {str(e)}"
|
|
232
245
|
) 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,84 @@
|
|
|
1
|
+
"""Conditional authentication dependencies."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
6
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
7
|
+
|
|
8
|
+
from ccproxy.auth.bearer import BearerTokenAuthManager
|
|
9
|
+
from ccproxy.auth.exceptions import AuthenticationError
|
|
10
|
+
from ccproxy.auth.manager import AuthManager
|
|
11
|
+
from ccproxy.config.settings import Settings, get_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# FastAPI security scheme for bearer tokens
|
|
15
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_conditional_auth_manager(
|
|
19
|
+
request: Request,
|
|
20
|
+
credentials: Annotated[
|
|
21
|
+
HTTPAuthorizationCredentials | None, Depends(bearer_scheme)
|
|
22
|
+
] = None,
|
|
23
|
+
settings: Annotated[Settings | None, Depends(get_settings)] = None,
|
|
24
|
+
) -> AuthManager | None:
|
|
25
|
+
"""Get authentication manager only if auth is required.
|
|
26
|
+
|
|
27
|
+
This dependency checks if authentication is configured and validates
|
|
28
|
+
the token if required. If no auth is configured, returns None.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request: The FastAPI request object
|
|
32
|
+
credentials: HTTP authorization credentials
|
|
33
|
+
settings: Application settings
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
AuthManager instance if authenticated, None if no auth required
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
HTTPException: If auth is required but credentials are invalid
|
|
40
|
+
"""
|
|
41
|
+
# Check if auth is required for this configuration
|
|
42
|
+
if settings is None or not settings.security.auth_token:
|
|
43
|
+
# No auth configured, return None
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
# Auth is required, validate credentials
|
|
47
|
+
if not credentials or not credentials.credentials:
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
50
|
+
detail="Authentication required",
|
|
51
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Validate the token
|
|
55
|
+
if credentials.credentials != settings.security.auth_token:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
58
|
+
detail="Invalid authentication credentials",
|
|
59
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Create and return auth manager
|
|
63
|
+
try:
|
|
64
|
+
bearer_auth = BearerTokenAuthManager(credentials.credentials)
|
|
65
|
+
if await bearer_auth.is_authenticated():
|
|
66
|
+
return bearer_auth
|
|
67
|
+
else:
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
70
|
+
detail="Authentication failed",
|
|
71
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
72
|
+
)
|
|
73
|
+
except (AuthenticationError, ValueError) as e:
|
|
74
|
+
raise HTTPException(
|
|
75
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
76
|
+
detail=str(e),
|
|
77
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
78
|
+
) from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Type alias for conditional auth dependency
|
|
82
|
+
ConditionalAuthDep = Annotated[
|
|
83
|
+
AuthManager | None, Depends(get_conditional_auth_manager)
|
|
84
|
+
]
|
ccproxy/cli/commands/auth.py
CHANGED
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
from datetime import UTC, datetime, timezone
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Optional
|
|
7
|
+
from typing import Annotated, Optional
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
from rich import box
|
|
@@ -52,16 +52,20 @@ def get_docker_credential_paths() -> list[Path]:
|
|
|
52
52
|
|
|
53
53
|
@app.command(name="validate")
|
|
54
54
|
def validate_credentials(
|
|
55
|
-
docker:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
docker: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--docker",
|
|
59
|
+
help="Use Docker credential paths (from get_claude_docker_home_dir())",
|
|
60
|
+
),
|
|
61
|
+
] = False,
|
|
62
|
+
credential_file: Annotated[
|
|
63
|
+
str | None,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--credential-file",
|
|
66
|
+
help="Path to specific credential file to validate",
|
|
67
|
+
),
|
|
68
|
+
] = None,
|
|
65
69
|
) -> None:
|
|
66
70
|
"""Validate Claude CLI credentials.
|
|
67
71
|
|
|
@@ -176,16 +180,20 @@ def validate_credentials(
|
|
|
176
180
|
|
|
177
181
|
@app.command(name="info")
|
|
178
182
|
def credential_info(
|
|
179
|
-
docker:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
docker: Annotated[
|
|
184
|
+
bool,
|
|
185
|
+
typer.Option(
|
|
186
|
+
"--docker",
|
|
187
|
+
help="Use Docker credential paths (from get_claude_docker_home_dir())",
|
|
188
|
+
),
|
|
189
|
+
] = False,
|
|
190
|
+
credential_file: Annotated[
|
|
191
|
+
str | None,
|
|
192
|
+
typer.Option(
|
|
193
|
+
"--credential-file",
|
|
194
|
+
help="Path to specific credential file to display info for",
|
|
195
|
+
),
|
|
196
|
+
] = None,
|
|
189
197
|
) -> None:
|
|
190
198
|
"""Display detailed credential information.
|
|
191
199
|
|
|
@@ -376,16 +384,20 @@ def credential_info(
|
|
|
376
384
|
|
|
377
385
|
@app.command(name="login")
|
|
378
386
|
def login_command(
|
|
379
|
-
docker:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
docker: Annotated[
|
|
388
|
+
bool,
|
|
389
|
+
typer.Option(
|
|
390
|
+
"--docker",
|
|
391
|
+
help="Use Docker credential paths (from get_claude_docker_home_dir())",
|
|
392
|
+
),
|
|
393
|
+
] = False,
|
|
394
|
+
credential_file: Annotated[
|
|
395
|
+
str | None,
|
|
396
|
+
typer.Option(
|
|
397
|
+
"--credential-file",
|
|
398
|
+
help="Path to specific credential file to save to",
|
|
399
|
+
),
|
|
400
|
+
] = None,
|
|
389
401
|
) -> None:
|
|
390
402
|
"""Login to Claude using OAuth authentication.
|
|
391
403
|
|
|
@@ -470,18 +482,22 @@ def login_command(
|
|
|
470
482
|
|
|
471
483
|
@app.command()
|
|
472
484
|
def renew(
|
|
473
|
-
docker:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
+
docker: Annotated[
|
|
486
|
+
bool,
|
|
487
|
+
typer.Option(
|
|
488
|
+
"--docker",
|
|
489
|
+
"-d",
|
|
490
|
+
help="Renew credentials for Docker environment",
|
|
491
|
+
),
|
|
492
|
+
] = False,
|
|
493
|
+
credential_file: Annotated[
|
|
494
|
+
Path | None,
|
|
495
|
+
typer.Option(
|
|
496
|
+
"--credential-file",
|
|
497
|
+
"-f",
|
|
498
|
+
help="Path to custom credential file",
|
|
499
|
+
),
|
|
500
|
+
] = None,
|
|
485
501
|
) -> None:
|
|
486
502
|
"""Force renew Claude credentials without checking expiration.
|
|
487
503
|
|
|
@@ -361,7 +361,7 @@ def generate_token(
|
|
|
361
361
|
|
|
362
362
|
# Show environment variable commands - server first, then clients
|
|
363
363
|
console.print("[bold]Server Environment Variables:[/bold]")
|
|
364
|
-
console.print(f"[cyan]export
|
|
364
|
+
console.print(f"[cyan]export SECURITY__AUTH_TOKEN={token}[/cyan]")
|
|
365
365
|
console.print()
|
|
366
366
|
|
|
367
367
|
console.print("[bold]Client Environment Variables:[/bold]")
|
|
@@ -380,7 +380,7 @@ def generate_token(
|
|
|
380
380
|
console.print()
|
|
381
381
|
|
|
382
382
|
console.print("[bold]For .env file:[/bold]")
|
|
383
|
-
console.print(f"[cyan]
|
|
383
|
+
console.print(f"[cyan]SECURITY__AUTH_TOKEN={token}[/cyan]")
|
|
384
384
|
console.print()
|
|
385
385
|
|
|
386
386
|
console.print("[bold]Usage with curl (using environment variables):[/bold]")
|