ccproxy-api 0.1.1__py3-none-any.whl → 0.1.3__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 (107) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +65 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +88 -19
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -1
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +52 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +6 -17
  45. ccproxy/cli/options/claude_options.py +41 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +15 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +64 -1
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +225 -329
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
  104. ccproxy_api-0.1.1.dist-info/RECORD +0 -149
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,171 @@
1
+ """MCP (Model Context Protocol) server for CCProxy API Server.
2
+
3
+ Provides MCP server functionality including permission checking tools.
4
+ """
5
+
6
+ from typing import Annotated
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi_mcp import FastApiMCP # type: ignore[import-untyped]
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+ from structlog import get_logger
12
+
13
+ from ccproxy.api.dependencies import SettingsDep
14
+ from ccproxy.api.services.permission_service import get_permission_service
15
+ from ccproxy.models.permissions import PermissionStatus
16
+ from ccproxy.models.responses import (
17
+ PermissionToolAllowResponse,
18
+ PermissionToolDenyResponse,
19
+ PermissionToolPendingResponse,
20
+ )
21
+
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class PermissionCheckRequest(BaseModel):
27
+ """Request model for permission checking."""
28
+
29
+ tool_name: Annotated[
30
+ str, Field(description="Name of the tool to check permissions for")
31
+ ]
32
+ input: Annotated[dict[str, str], Field(description="Input parameters for the tool")]
33
+ tool_use_id: Annotated[
34
+ str | None,
35
+ Field(
36
+ description="Id of the tool execution",
37
+ ),
38
+ ] = None
39
+ permission_id: Annotated[
40
+ str | None,
41
+ Field(
42
+ description="ID of a previous permission request for retry",
43
+ alias="permissionId",
44
+ ),
45
+ ] = None
46
+
47
+ model_config = ConfigDict(populate_by_name=True)
48
+
49
+
50
+ async def check_permission(
51
+ request: PermissionCheckRequest,
52
+ settings: SettingsDep,
53
+ ) -> (
54
+ PermissionToolAllowResponse
55
+ | PermissionToolDenyResponse
56
+ | PermissionToolPendingResponse
57
+ ):
58
+ """Check permissions for a tool call.
59
+
60
+ This implements the same security logic as the CLI permission tool,
61
+ checking for dangerous patterns and restricted tools.
62
+ """
63
+ logger.info(
64
+ "permission_check",
65
+ tool_name=request.tool_name,
66
+ retry=request.permission_id is not None,
67
+ )
68
+
69
+ permission_service = get_permission_service()
70
+
71
+ if request.permission_id:
72
+ status = await permission_service.get_status(request.permission_id)
73
+
74
+ if status == PermissionStatus.ALLOWED:
75
+ return PermissionToolAllowResponse(updated_input=request.input)
76
+
77
+ elif status == PermissionStatus.DENIED:
78
+ return PermissionToolDenyResponse(message="User denied the operation")
79
+
80
+ elif status == PermissionStatus.EXPIRED:
81
+ return PermissionToolDenyResponse(message="Permission request expired")
82
+
83
+ logger.info(
84
+ "permission_requires_authorization",
85
+ tool_name=request.tool_name,
86
+ )
87
+
88
+ permission_id = await permission_service.request_permission(
89
+ tool_name=request.tool_name,
90
+ input=request.input,
91
+ )
92
+
93
+ # Wait for permission to be resolved
94
+ try:
95
+ final_status = await permission_service.wait_for_permission(
96
+ permission_id,
97
+ timeout_seconds=settings.security.confirmation_timeout_seconds,
98
+ )
99
+
100
+ if final_status == PermissionStatus.ALLOWED:
101
+ logger.info(
102
+ "permission_allowed_after_authorization",
103
+ tool_name=request.tool_name,
104
+ permission_id=permission_id,
105
+ )
106
+ return PermissionToolAllowResponse(updated_input=request.input)
107
+ else:
108
+ logger.info(
109
+ "permission_denied_after_authorization",
110
+ tool_name=request.tool_name,
111
+ permission_id=permission_id,
112
+ status=final_status.value,
113
+ )
114
+ return PermissionToolDenyResponse(
115
+ message=f"User denied the operation (status: {final_status.value})"
116
+ )
117
+
118
+ except TimeoutError:
119
+ logger.warning(
120
+ "permission_authorization_timeout",
121
+ tool_name=request.tool_name,
122
+ permission_id=permission_id,
123
+ timeout_seconds=settings.security.confirmation_timeout_seconds,
124
+ )
125
+ return PermissionToolDenyResponse(message="Permission request timed out")
126
+
127
+
128
+ def setup_mcp(app: FastAPI) -> None:
129
+ """Set up MCP server on the given FastAPI app.
130
+
131
+ Args:
132
+ app: The FastAPI application to mount MCP on
133
+ """
134
+ # Minimal MCP sub-app without middleware or docs
135
+ mcp_app = FastAPI(
136
+ title="CCProxy MCP Server",
137
+ description="MCP server for Claude Code permission checking",
138
+ openapi_url=None,
139
+ docs_url=None,
140
+ redoc_url=None,
141
+ )
142
+
143
+ @mcp_app.post(
144
+ "/permission/check",
145
+ operation_id="check_permission",
146
+ summary="Check permissions for a tool call",
147
+ description="Validates whether a tool call should be allowed based on security rules",
148
+ response_model=PermissionToolAllowResponse
149
+ | PermissionToolDenyResponse
150
+ | PermissionToolPendingResponse,
151
+ tags=["mcp-tools"],
152
+ )
153
+ async def permission_endpoint(
154
+ request: PermissionCheckRequest,
155
+ settings: SettingsDep,
156
+ ) -> (
157
+ PermissionToolAllowResponse
158
+ | PermissionToolDenyResponse
159
+ | PermissionToolPendingResponse
160
+ ):
161
+ """Check permissions for a tool call."""
162
+ return await check_permission(request, settings)
163
+
164
+ mcp = FastApiMCP(
165
+ mcp_app,
166
+ name="CCProxy MCP Server",
167
+ description="MCP server for Claude Code permission checking",
168
+ include_operations=["check_permission"],
169
+ )
170
+
171
+ mcp.mount(app, mount_path="/mcp")
@@ -2,16 +2,15 @@
2
2
 
3
3
  import time
4
4
  from datetime import datetime as dt
5
- from typing import Any, Optional, cast
5
+ from typing import Any, cast
6
6
 
7
- from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
7
+ from fastapi import APIRouter, HTTPException, Query, Request, Response
8
8
  from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
9
9
  from sqlmodel import Session, col, desc, func, select
10
10
  from typing_extensions import TypedDict
11
11
 
12
12
  from ccproxy.api.dependencies import (
13
13
  DuckDBStorageDep,
14
- LogStorageDep,
15
14
  ObservabilityMetricsDep,
16
15
  SettingsDep,
17
16
  )
@@ -84,12 +83,9 @@ class AnalyticsResult(TypedDict):
84
83
 
85
84
  # Create separate routers for different concerns
86
85
  prometheus_router = APIRouter(tags=["metrics"])
87
- logs_router = APIRouter(prefix="/logs", tags=["logs"])
86
+ logs_router = APIRouter(tags=["logs"])
88
87
  dashboard_router = APIRouter(tags=["dashboard"])
89
88
 
90
- # Backward compatibility - keep the old router name pointing to logs for now
91
- router = logs_router
92
-
93
89
 
94
90
  @logs_router.get("/status")
95
91
  async def logs_status(metrics: ObservabilityMetricsDep) -> dict[str, str]:
@@ -200,7 +196,7 @@ async def get_prometheus_metrics(metrics: ObservabilityMetricsDep) -> Response:
200
196
  )
201
197
 
202
198
  # Generate prometheus format using the registry
203
- from prometheus_client import REGISTRY, CollectorRegistry
199
+ from prometheus_client import REGISTRY
204
200
 
205
201
  # Use the global registry if metrics.registry is None (default behavior)
206
202
  registry = metrics.registry if metrics.registry is not None else REGISTRY
@@ -0,0 +1,217 @@
1
+ """API routes for permission request handling via SSE and REST."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncGenerator
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel
9
+ from sse_starlette.sse import EventSourceResponse
10
+ from structlog import get_logger
11
+
12
+ from ccproxy.api.dependencies import SettingsDep
13
+ from ccproxy.api.services.permission_service import get_permission_service
14
+ from ccproxy.auth.conditional import ConditionalAuthDep
15
+ from ccproxy.core.errors import (
16
+ PermissionAlreadyResolvedError,
17
+ PermissionNotFoundError,
18
+ )
19
+ from ccproxy.models.permissions import EventType, PermissionEvent, PermissionStatus
20
+
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ router = APIRouter(tags=["permissions"])
26
+
27
+
28
+ class PermissionResponse(BaseModel):
29
+ """Response to a permission request."""
30
+
31
+ allowed: bool
32
+
33
+
34
+ class PermissionRequestInfo(BaseModel):
35
+ """Information about a permission request."""
36
+
37
+ request_id: str
38
+ tool_name: str
39
+ input: dict[str, str]
40
+ status: str
41
+ created_at: str
42
+ expires_at: str
43
+ time_remaining: int
44
+
45
+
46
+ async def event_generator(
47
+ request: Request,
48
+ ) -> AsyncGenerator[dict[str, str], None]:
49
+ """Generate SSE events for permission requests.
50
+
51
+ Args:
52
+ request: The FastAPI request object
53
+
54
+ Yields:
55
+ Dict with event data for SSE
56
+ """
57
+ service = get_permission_service()
58
+ queue = await service.subscribe_to_events()
59
+
60
+ try:
61
+ yield {
62
+ "event": "ping",
63
+ "data": json.dumps({"message": "Connected to permission stream"}),
64
+ }
65
+
66
+ # Send all pending permission requests to the newly connected client
67
+ pending_requests = await service.get_pending_requests()
68
+ for pending_req in pending_requests:
69
+ event = PermissionEvent(
70
+ type=EventType.PERMISSION_REQUEST,
71
+ request_id=pending_req.id,
72
+ tool_name=pending_req.tool_name,
73
+ input=pending_req.input,
74
+ created_at=pending_req.created_at.isoformat(),
75
+ expires_at=pending_req.expires_at.isoformat(),
76
+ timeout_seconds=int(
77
+ (pending_req.expires_at - pending_req.created_at).total_seconds()
78
+ ),
79
+ )
80
+ yield {
81
+ "event": EventType.PERMISSION_REQUEST.value,
82
+ "data": json.dumps(event.model_dump(mode="json")),
83
+ }
84
+
85
+ while not await request.is_disconnected():
86
+ try:
87
+ event_data = await asyncio.wait_for(queue.get(), timeout=30.0)
88
+
89
+ yield {
90
+ "event": event_data.get("type", "message"),
91
+ "data": json.dumps(event_data),
92
+ }
93
+
94
+ except TimeoutError:
95
+ yield {
96
+ "event": "ping",
97
+ "data": json.dumps({"message": "keepalive"}),
98
+ }
99
+
100
+ except asyncio.CancelledError:
101
+ pass
102
+ finally:
103
+ await service.unsubscribe_from_events(queue)
104
+
105
+
106
+ @router.get("/stream")
107
+ async def stream_permissions(
108
+ request: Request,
109
+ settings: SettingsDep,
110
+ auth: ConditionalAuthDep,
111
+ ) -> EventSourceResponse:
112
+ """Stream permission requests via Server-Sent Events.
113
+
114
+ This endpoint streams new permission requests as they are created,
115
+ allowing external tools to handle user permissions.
116
+
117
+ Returns:
118
+ EventSourceResponse streaming permission events
119
+ """
120
+ return EventSourceResponse(
121
+ event_generator(request),
122
+ headers={
123
+ "Cache-Control": "no-cache",
124
+ "X-Accel-Buffering": "no", # Disable nginx buffering
125
+ },
126
+ )
127
+
128
+
129
+ @router.get("/{permission_id}")
130
+ async def get_permission(
131
+ permission_id: str,
132
+ settings: SettingsDep,
133
+ auth: ConditionalAuthDep,
134
+ ) -> PermissionRequestInfo:
135
+ """Get information about a specific permission request.
136
+
137
+ Args:
138
+ permission_id: ID of the permission request
139
+
140
+ Returns:
141
+ Information about the permission request
142
+
143
+ Raises:
144
+ HTTPException: If request not found
145
+ """
146
+ service = get_permission_service()
147
+ try:
148
+ request = await service.get_request(permission_id)
149
+ if not request:
150
+ raise PermissionNotFoundError(permission_id)
151
+ except PermissionNotFoundError as e:
152
+ raise HTTPException(
153
+ status_code=404, detail="Permission request not found"
154
+ ) from e
155
+
156
+ return PermissionRequestInfo(
157
+ request_id=request.id,
158
+ tool_name=request.tool_name,
159
+ input=request.input,
160
+ status=request.status.value,
161
+ created_at=request.created_at.isoformat(),
162
+ expires_at=request.expires_at.isoformat(),
163
+ time_remaining=request.time_remaining(),
164
+ )
165
+
166
+
167
+ @router.post("/{permission_id}/respond")
168
+ async def respond_to_permission(
169
+ permission_id: str,
170
+ response: PermissionResponse,
171
+ settings: SettingsDep,
172
+ auth: ConditionalAuthDep,
173
+ ) -> dict[str, str | bool]:
174
+ """Submit a response to a permission request.
175
+
176
+ Args:
177
+ permission_id: ID of the permission request
178
+ response: The allow/deny response
179
+
180
+ Returns:
181
+ Success response
182
+
183
+ Raises:
184
+ HTTPException: If request not found or already resolved
185
+ """
186
+ service = get_permission_service()
187
+ status = await service.get_status(permission_id)
188
+ if status is None:
189
+ raise HTTPException(status_code=404, detail="Permission request not found")
190
+
191
+ if status != PermissionStatus.PENDING:
192
+ try:
193
+ raise PermissionAlreadyResolvedError(permission_id, status.value)
194
+ except PermissionAlreadyResolvedError as e:
195
+ raise HTTPException(
196
+ status_code=e.status_code,
197
+ detail=e.message,
198
+ ) from e
199
+
200
+ success = await service.resolve(permission_id, response.allowed)
201
+
202
+ if not success:
203
+ raise HTTPException(
204
+ status_code=409, detail="Failed to resolve permission request"
205
+ )
206
+
207
+ logger.info(
208
+ "permission_resolved_via_api",
209
+ permission_id=permission_id,
210
+ allowed=response.allowed,
211
+ )
212
+
213
+ return {
214
+ "status": "success",
215
+ "permission_id": permission_id,
216
+ "allowed": response.allowed,
217
+ }
@@ -2,17 +2,14 @@
2
2
 
3
3
  import json
4
4
  from collections.abc import AsyncIterator
5
- from typing import Any
6
5
 
7
6
  from fastapi import APIRouter, HTTPException, Request, Response
8
7
  from fastapi.responses import StreamingResponse
9
- from starlette.background import BackgroundTask
10
8
 
11
9
  from ccproxy.adapters.openai.adapter import OpenAIAdapter
12
10
  from ccproxy.api.dependencies import ProxyServiceDep
13
11
  from ccproxy.api.responses import ProxyResponse
14
12
  from ccproxy.auth.conditional import ConditionalAuthDep
15
- from ccproxy.core.errors import ProxyHTTPException
16
13
 
17
14
 
18
15
  # Create the router for proxy endpoints
@@ -193,53 +190,3 @@ async def create_anthropic_message(
193
190
  raise HTTPException(
194
191
  status_code=500, detail=f"Internal server error: {str(e)}"
195
192
  ) from e
196
-
197
-
198
- @router.get("/v1/models", response_model=None)
199
- async def list_models(
200
- request: Request,
201
- proxy_service: ProxyServiceDep,
202
- auth: ConditionalAuthDep,
203
- ) -> Response:
204
- """List available models using the proxy service.
205
-
206
- Returns a combined list of Anthropic models and recent OpenAI models.
207
- """
208
- try:
209
- # Get headers
210
- headers = dict(request.headers)
211
-
212
- # Handle the request using proxy service
213
- response = await proxy_service.handle_request(
214
- method="GET",
215
- path="/v1/models",
216
- headers=headers,
217
- body=None,
218
- request=request,
219
- )
220
-
221
- # Since /v1/models never streams, we know it returns a tuple
222
- if isinstance(response, tuple):
223
- status_code, response_headers, response_body = response
224
- else:
225
- # This shouldn't happen for /v1/models, but handle it gracefully
226
- raise HTTPException(
227
- status_code=500,
228
- detail="Unexpected streaming response for /v1/models endpoint",
229
- )
230
-
231
- # Return response with headers
232
- return ProxyResponse(
233
- content=response_body,
234
- status_code=status_code,
235
- headers=response_headers,
236
- media_type=response_headers.get("content-type", "application/json"),
237
- )
238
-
239
- except HTTPException:
240
- # Re-raise HTTPException as-is (including 401 auth errors)
241
- raise
242
- except Exception as e:
243
- raise HTTPException(
244
- status_code=500, detail=f"Internal server error: {str(e)}"
245
- ) from e
@@ -0,0 +1,6 @@
1
+ """Services for CCProxy API."""
2
+
3
+ from .permission_service import PermissionService, get_permission_service
4
+
5
+
6
+ __all__ = ["PermissionService", "get_permission_service"]