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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +65 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +88 -19
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -1
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +52 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +6 -17
- ccproxy/cli/options/claude_options.py +41 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +15 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +64 -1
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +225 -329
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.3.dist-info/RECORD +166 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
- ccproxy_api-0.1.1.dist-info/RECORD +0 -149
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
- {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")
|
ccproxy/api/routes/metrics.py
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
4
|
from datetime import datetime as dt
|
|
5
|
-
from typing import Any,
|
|
5
|
+
from typing import Any, cast
|
|
6
6
|
|
|
7
|
-
from fastapi import APIRouter,
|
|
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(
|
|
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
|
|
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
|
+
}
|
ccproxy/api/routes/proxy.py
CHANGED
|
@@ -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
|