ccproxy-api 0.1.0__py3-none-any.whl → 0.1.2__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 CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.1.0'
21
- __version_tuple__ = version_tuple = (0, 1, 0)
20
+ __version__ = version = '0.1.2'
21
+ __version_tuple__ = version_tuple = (0, 1, 2)
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)
@@ -22,11 +22,13 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
22
22
 
23
23
 
24
24
  def get_claude_service(
25
+ settings: SettingsDep,
25
26
  auth_manager: AuthManagerDep,
26
27
  ) -> ClaudeSDKService:
27
28
  """Get Claude SDK service instance.
28
29
 
29
30
  Args:
31
+ settings: Application settings dependency
30
32
  auth_manager: Authentication manager dependency
31
33
 
32
34
  Returns:
@@ -39,6 +41,7 @@ def get_claude_service(
39
41
  return ClaudeSDKService(
40
42
  auth_manager=auth_manager,
41
43
  metrics=metrics,
44
+ settings=settings,
42
45
  )
43
46
 
44
47
 
@@ -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"}
@@ -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
+ ]
@@ -109,7 +109,7 @@ class MessageConverter:
109
109
  for block in contents:
110
110
  text_parts.append(MessageConverter.extract_text_from_content(block))
111
111
 
112
- return " ".join(text_parts)
112
+ return "\n".join(text_parts)
113
113
 
114
114
  @staticmethod
115
115
  def convert_to_anthropic_response(
@@ -2,6 +2,7 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
+ from ccproxy.config.settings import Settings
5
6
  from ccproxy.core.async_utils import patched_typing
6
7
 
7
8
 
@@ -14,8 +15,17 @@ class OptionsHandler:
14
15
  Handles creation and management of Claude SDK options.
15
16
  """
16
17
 
17
- @staticmethod
18
+ def __init__(self, settings: Settings | None = None) -> None:
19
+ """
20
+ Initialize options handler.
21
+
22
+ Args:
23
+ settings: Application settings containing default Claude options
24
+ """
25
+ self.settings = settings
26
+
18
27
  def create_options(
28
+ self,
19
29
  model: str,
20
30
  temperature: float | None = None,
21
31
  max_tokens: int | None = None,
@@ -37,6 +47,18 @@ class OptionsHandler:
37
47
  """
38
48
  options = ClaudeCodeOptions(model=model)
39
49
 
50
+ # First apply settings from configuration if available
51
+ if self.settings and self.settings.claude.code_options:
52
+ code_opts = self.settings.claude.code_options
53
+
54
+ # Apply settings from configuration
55
+ for attr_name in dir(code_opts):
56
+ if not attr_name.startswith("_"):
57
+ value = getattr(code_opts, attr_name, None)
58
+ if value is not None and hasattr(options, attr_name):
59
+ setattr(options, attr_name, value)
60
+
61
+ # Then apply API parameters (these override settings)
40
62
  if temperature is not None:
41
63
  options.temperature = temperature # type: ignore[attr-defined]
42
64
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  from .auth import app as auth_app
4
4
  from .config import app as config_app
5
+ from .permission import app as permission_app
5
6
  from .serve import api
6
7
 
7
8
 
8
- __all__ = ["api", "auth_app", "config_app"]
9
+ __all__ = ["api", "auth_app", "config_app", "permission_app"]
@@ -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: bool = typer.Option(
56
- False,
57
- "--docker",
58
- help="Use Docker credential paths (from get_claude_docker_home_dir())",
59
- ),
60
- credential_file: str | None = typer.Option(
61
- None,
62
- "--credential-file",
63
- help="Path to specific credential file to validate",
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: bool = typer.Option(
180
- False,
181
- "--docker",
182
- help="Use Docker credential paths (from get_claude_docker_home_dir())",
183
- ),
184
- credential_file: str | None = typer.Option(
185
- None,
186
- "--credential-file",
187
- help="Path to specific credential file to display info for",
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: bool = typer.Option(
380
- False,
381
- "--docker",
382
- help="Use Docker credential paths (from get_claude_docker_home_dir())",
383
- ),
384
- credential_file: str | None = typer.Option(
385
- None,
386
- "--credential-file",
387
- help="Path to specific credential file to save to",
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: bool = typer.Option(
474
- False,
475
- "--docker",
476
- "-d",
477
- help="Renew credentials for Docker environment",
478
- ),
479
- credential_file: Path | None = typer.Option(
480
- None,
481
- "--credential-file",
482
- "-f",
483
- help="Path to custom credential file",
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 AUTH_TOKEN={token}[/cyan]")
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]AUTH_TOKEN={token}[/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]")
@@ -0,0 +1,128 @@
1
+ """MCP permission prompt tool for Claude Code SDK."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Annotated, Any, Optional
6
+
7
+ import typer
8
+ from structlog import get_logger
9
+
10
+ from ccproxy.config.settings import config_manager
11
+ from ccproxy.models.responses import (
12
+ PermissionToolAllowResponse,
13
+ PermissionToolDenyResponse,
14
+ )
15
+
16
+
17
+ app = typer.Typer(
18
+ rich_markup_mode="rich",
19
+ add_completion=False,
20
+ no_args_is_help=False,
21
+ pretty_exceptions_enable=False,
22
+ )
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ @app.command()
28
+ def permission_tool(
29
+ tool_name: Annotated[
30
+ str, typer.Argument(help="Name of the tool to check permissions for")
31
+ ],
32
+ tool_input: Annotated[str, typer.Argument(help="JSON string of the tool input")],
33
+ config: Annotated[
34
+ Path | None,
35
+ typer.Option(
36
+ "--config",
37
+ "-c",
38
+ help="Path to configuration file (TOML, JSON, or YAML)",
39
+ exists=True,
40
+ file_okay=True,
41
+ dir_okay=False,
42
+ readable=True,
43
+ ),
44
+ ] = None,
45
+ ) -> None:
46
+ """
47
+ MCP permission prompt tool for Claude Code SDK.
48
+
49
+ This tool is used by the Claude Code SDK to check permissions for tool calls.
50
+ It returns a JSON response indicating whether the tool call should be allowed or denied.
51
+
52
+ Response format:
53
+ - Allow: {"behavior": "allow", "updatedInput": {...}}
54
+ - Deny: {"behavior": "deny", "message": "reason"}
55
+
56
+ Examples:
57
+ ccproxy-perm "bash" '{"command": "ls -la"}'
58
+ ccproxy-perm "edit_file" '{"path": "/etc/passwd", "content": "..."}'
59
+ """
60
+
61
+ try:
62
+ # Parse the tool input JSON
63
+ try:
64
+ input_data = json.loads(tool_input)
65
+ except json.JSONDecodeError as e:
66
+ response = PermissionToolDenyResponse(message=f"Invalid JSON input: {e}")
67
+ print(response.model_dump_json(by_alias=True))
68
+ raise typer.Exit(1) from e
69
+
70
+ # Load settings to get permission configuration
71
+ settings = config_manager.load_settings(config_path=config)
72
+
73
+ # Basic permission checking logic
74
+ # This can be extended with more sophisticated rules
75
+
76
+ # Check for potentially dangerous commands
77
+ dangerous_patterns = [
78
+ "rm -rf",
79
+ "sudo",
80
+ "passwd",
81
+ "chmod 777",
82
+ "/etc/passwd",
83
+ "/etc/shadow",
84
+ "format",
85
+ "mkfs",
86
+ ]
87
+
88
+ # Convert input to string for pattern matching
89
+ input_str = json.dumps(input_data).lower()
90
+
91
+ # Check for dangerous patterns
92
+ for pattern in dangerous_patterns:
93
+ if pattern in input_str:
94
+ response = PermissionToolDenyResponse(
95
+ message=f"Tool call contains potentially dangerous pattern: {pattern}"
96
+ )
97
+ print(response.model_dump_json(by_alias=True))
98
+ return
99
+
100
+ # Check for specific tool restrictions
101
+ restricted_tools = {"exec", "system", "shell", "subprocess"}
102
+
103
+ if tool_name.lower() in restricted_tools:
104
+ response = PermissionToolDenyResponse(
105
+ message=f"Tool {tool_name} is restricted for security reasons"
106
+ )
107
+ print(response.model_dump_json(by_alias=True))
108
+ return
109
+
110
+ # Allow the tool call with original input
111
+ allow_response = PermissionToolAllowResponse(updated_input=input_data)
112
+ print(allow_response.model_dump_json(by_alias=True))
113
+
114
+ except Exception as e:
115
+ error_response = PermissionToolDenyResponse(
116
+ message=f"Error processing permission request: {e}"
117
+ )
118
+ print(error_response.model_dump_json(by_alias=True))
119
+ raise typer.Exit(1) from e
120
+
121
+
122
+ def main() -> None:
123
+ """Entry point for ccproxy-perm command."""
124
+ app()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()