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,368 @@
1
+ """Permission service for handling permission requests without UI dependencies."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Any
7
+
8
+ from structlog import get_logger
9
+
10
+ from ccproxy.core.errors import (
11
+ PermissionNotFoundError,
12
+ )
13
+ from ccproxy.models.permissions import (
14
+ EventType,
15
+ PermissionEvent,
16
+ PermissionRequest,
17
+ PermissionStatus,
18
+ )
19
+
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class PermissionService:
25
+ """Service for managing permission requests without UI dependencies."""
26
+
27
+ def __init__(self, timeout_seconds: int = 30):
28
+ self._timeout_seconds = timeout_seconds
29
+ self._requests: dict[str, PermissionRequest] = {}
30
+ self._expiry_task: asyncio.Task[None] | None = None
31
+ self._shutdown = False
32
+ self._event_queues: list[asyncio.Queue[dict[str, Any]]] = []
33
+ self._lock = asyncio.Lock()
34
+
35
+ async def start(self) -> None:
36
+ if self._expiry_task is None:
37
+ self._expiry_task = asyncio.create_task(self._expiry_checker())
38
+ logger.info("permission_service_started")
39
+
40
+ async def stop(self) -> None:
41
+ self._shutdown = True
42
+ if self._expiry_task:
43
+ self._expiry_task.cancel()
44
+ with contextlib.suppress(asyncio.CancelledError):
45
+ await self._expiry_task
46
+ self._expiry_task = None
47
+ logger.info("permission_service_stopped")
48
+
49
+ async def request_permission(self, tool_name: str, input: dict[str, str]) -> str:
50
+ """Create a new permission request.
51
+
52
+ Args:
53
+ tool_name: Name of the tool requesting permission
54
+ input: Input parameters for the tool
55
+
56
+ Returns:
57
+ Permission request ID
58
+
59
+ Raises:
60
+ ValueError: If tool_name is empty or input is None
61
+ """
62
+ # Input validation
63
+ if not tool_name or not tool_name.strip():
64
+ raise ValueError("Tool name cannot be empty")
65
+ if input is None:
66
+ raise ValueError("Input parameters cannot be None")
67
+
68
+ # Sanitize input - ensure all values are strings
69
+ sanitized_input = {k: str(v) for k, v in input.items()}
70
+
71
+ now = datetime.now(UTC)
72
+ request = PermissionRequest(
73
+ tool_name=tool_name.strip(),
74
+ input=sanitized_input,
75
+ created_at=now,
76
+ expires_at=now + timedelta(seconds=self._timeout_seconds),
77
+ )
78
+
79
+ async with self._lock:
80
+ self._requests[request.id] = request
81
+
82
+ logger.info(
83
+ "permission_request_created",
84
+ request_id=request.id,
85
+ tool_name=tool_name,
86
+ )
87
+
88
+ event = PermissionEvent(
89
+ type=EventType.PERMISSION_REQUEST,
90
+ request_id=request.id,
91
+ tool_name=request.tool_name,
92
+ input=request.input,
93
+ created_at=request.created_at.isoformat(),
94
+ expires_at=request.expires_at.isoformat(),
95
+ timeout_seconds=self._timeout_seconds,
96
+ )
97
+ await self._emit_event(event.model_dump(mode="json"))
98
+
99
+ return request.id
100
+
101
+ async def get_status(self, request_id: str) -> PermissionStatus | None:
102
+ """Get the status of a permission request.
103
+
104
+ Args:
105
+ request_id: ID of the permission request
106
+
107
+ Returns:
108
+ Status of the request or None if not found
109
+ """
110
+ async with self._lock:
111
+ request = self._requests.get(request_id)
112
+ if not request:
113
+ return None
114
+
115
+ if request.is_expired():
116
+ request.status = PermissionStatus.EXPIRED
117
+
118
+ return request.status
119
+
120
+ async def get_request(self, request_id: str) -> PermissionRequest | None:
121
+ """Get a permission request by ID.
122
+
123
+ Args:
124
+ request_id: ID of the permission request
125
+
126
+ Returns:
127
+ The request or None if not found
128
+ """
129
+ async with self._lock:
130
+ return self._requests.get(request_id)
131
+
132
+ async def resolve(self, request_id: str, allowed: bool) -> bool:
133
+ """Manually resolve a permission request.
134
+
135
+ Args:
136
+ request_id: ID of the permission request
137
+ allowed: Whether to allow or deny the request
138
+
139
+ Returns:
140
+ True if resolved successfully, False if not found or already resolved
141
+
142
+ Raises:
143
+ ValueError: If request_id is empty
144
+ """
145
+ # Input validation
146
+ if not request_id or not request_id.strip():
147
+ raise ValueError("Request ID cannot be empty")
148
+
149
+ async with self._lock:
150
+ request = self._requests.get(request_id.strip())
151
+ if not request or request.status != PermissionStatus.PENDING:
152
+ return False
153
+
154
+ try:
155
+ request.resolve(allowed)
156
+ except ValueError:
157
+ return False
158
+
159
+ logger.info(
160
+ "permission_request_resolved",
161
+ request_id=request_id,
162
+ tool_name=request.tool_name,
163
+ allowed=allowed,
164
+ )
165
+
166
+ # Emit resolution event
167
+ event = PermissionEvent(
168
+ type=EventType.PERMISSION_RESOLVED,
169
+ request_id=request_id,
170
+ allowed=allowed,
171
+ resolved_at=request.resolved_at.isoformat()
172
+ if request.resolved_at
173
+ else None,
174
+ )
175
+ await self._emit_event(event.model_dump(mode="json"))
176
+
177
+ return True
178
+
179
+ async def _expiry_checker(self) -> None:
180
+ while not self._shutdown:
181
+ try:
182
+ await asyncio.sleep(5)
183
+
184
+ now = datetime.now(UTC)
185
+ expired_ids = []
186
+ expired_events = []
187
+
188
+ async with self._lock:
189
+ for req_id, req in self._requests.items():
190
+ if req.is_expired() and req.status == PermissionStatus.PENDING:
191
+ req.status = PermissionStatus.EXPIRED
192
+ # Signal waiting coroutines that the request is resolved (expired)
193
+ req._resolved_event.set()
194
+ event = PermissionEvent(
195
+ type=EventType.PERMISSION_EXPIRED,
196
+ request_id=req_id,
197
+ expired_at=now.isoformat(),
198
+ )
199
+ expired_events.append(event.model_dump(mode="json"))
200
+
201
+ if self._should_cleanup_request(req, now):
202
+ expired_ids.append(req_id)
203
+
204
+ for req_id in expired_ids:
205
+ del self._requests[req_id]
206
+
207
+ # Emit expired events outside the lock
208
+ for event_data in expired_events:
209
+ await self._emit_event(event_data)
210
+
211
+ if expired_ids:
212
+ logger.info(
213
+ "cleaned_expired_requests",
214
+ count=len(expired_ids),
215
+ )
216
+
217
+ except asyncio.CancelledError:
218
+ break
219
+ except Exception as e:
220
+ logger.error(
221
+ "expiry_checker_error",
222
+ error=str(e),
223
+ exc_info=True,
224
+ )
225
+
226
+ def _should_cleanup_request(
227
+ self, request: PermissionRequest, now: datetime
228
+ ) -> bool:
229
+ """Check if a resolved request should be cleaned up."""
230
+ if request.status == PermissionStatus.PENDING:
231
+ return False
232
+
233
+ cleanup_after = timedelta(minutes=5)
234
+
235
+ if request.resolved_at:
236
+ return (now - request.resolved_at) > cleanup_after
237
+
238
+ if request.status == PermissionStatus.EXPIRED:
239
+ return (now - request.expires_at) > cleanup_after
240
+
241
+ return False
242
+
243
+ async def subscribe_to_events(self) -> asyncio.Queue[dict[str, Any]]:
244
+ """Subscribe to permission events.
245
+
246
+ Returns:
247
+ An async queue that will receive events
248
+ """
249
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
250
+ async with self._lock:
251
+ self._event_queues.append(queue)
252
+ return queue
253
+
254
+ async def unsubscribe_from_events(
255
+ self, queue: asyncio.Queue[dict[str, Any]]
256
+ ) -> None:
257
+ """Unsubscribe from permission events.
258
+
259
+ Args:
260
+ queue: The queue to unsubscribe
261
+ """
262
+ async with self._lock:
263
+ if queue in self._event_queues:
264
+ self._event_queues.remove(queue)
265
+
266
+ async def _emit_event(self, event: dict[str, Any]) -> None:
267
+ """Emit an event to all subscribers.
268
+
269
+ Args:
270
+ event: The event data to emit
271
+ """
272
+ async with self._lock:
273
+ queues = list(self._event_queues)
274
+
275
+ if not queues:
276
+ return
277
+
278
+ for queue in queues:
279
+ with contextlib.suppress(asyncio.QueueFull):
280
+ queue.put_nowait(event)
281
+
282
+ async def get_pending_requests(self) -> list[PermissionRequest]:
283
+ """Get all pending permission requests.
284
+
285
+ Returns:
286
+ List of pending requests
287
+ """
288
+ async with self._lock:
289
+ pending = []
290
+ now = datetime.now(UTC)
291
+ for request in self._requests.values():
292
+ if request.is_expired():
293
+ request.status = PermissionStatus.EXPIRED
294
+ elif request.status == PermissionStatus.PENDING:
295
+ pending.append(request)
296
+ return pending
297
+
298
+ async def wait_for_permission(
299
+ self, request_id: str, timeout_seconds: int | None = None
300
+ ) -> PermissionStatus:
301
+ """Wait for a permission request to be resolved.
302
+
303
+ This method efficiently blocks until the permission is resolved (allowed/denied/expired)
304
+ or the timeout is reached using an event-driven approach.
305
+
306
+ Args:
307
+ request_id: ID of the permission request to wait for
308
+ timeout_seconds: Optional timeout in seconds. If None, uses request expiration time
309
+
310
+ Returns:
311
+ The final status of the permission request
312
+
313
+ Raises:
314
+ asyncio.TimeoutError: If timeout is reached before resolution
315
+ PermissionNotFoundError: If request ID is not found
316
+ """
317
+ async with self._lock:
318
+ request = self._requests.get(request_id)
319
+ if not request:
320
+ raise PermissionNotFoundError(request_id)
321
+
322
+ if request.status != PermissionStatus.PENDING:
323
+ return request.status
324
+
325
+ if timeout_seconds is None:
326
+ timeout_seconds = request.time_remaining()
327
+
328
+ try:
329
+ # Efficiently wait for the event to be set
330
+ await asyncio.wait_for(
331
+ request._resolved_event.wait(), timeout=timeout_seconds
332
+ )
333
+ except TimeoutError as e:
334
+ logger.warning(
335
+ "permission_wait_timeout",
336
+ request_id=request_id,
337
+ timeout_seconds=timeout_seconds,
338
+ )
339
+ # Ensure status is updated to EXPIRED on timeout
340
+ async with self._lock:
341
+ if request.status == PermissionStatus.PENDING:
342
+ request.status = PermissionStatus.EXPIRED
343
+ request._resolved_event.set() # Signal that it's resolved (as expired)
344
+ raise TimeoutError(
345
+ f"Confirmation wait timeout after {timeout_seconds:.1f}s"
346
+ ) from e
347
+
348
+ # The event is set, so the status is resolved
349
+ return await self.get_status(request_id) or PermissionStatus.EXPIRED
350
+
351
+
352
+ # Global instance
353
+ _permission_service: PermissionService | None = None
354
+
355
+
356
+ def get_permission_service() -> PermissionService:
357
+ """Get the global permission service instance."""
358
+ global _permission_service
359
+ if _permission_service is None:
360
+ _permission_service = PermissionService()
361
+ return _permission_service
362
+
363
+
364
+ __all__ = [
365
+ "PermissionService",
366
+ "PermissionRequest",
367
+ "get_permission_service",
368
+ ]
@@ -0,0 +1,6 @@
1
+ """UI components for CCProxy API."""
2
+
3
+ from .terminal_permission_handler import TerminalPermissionHandler
4
+
5
+
6
+ __all__ = ["TerminalPermissionHandler"]
@@ -0,0 +1,33 @@
1
+ """Protocol definition for confirmation handlers."""
2
+
3
+ from typing import Protocol
4
+
5
+ from ccproxy.api.services.permission_service import PermissionRequest
6
+
7
+
8
+ class ConfirmationHandlerProtocol(Protocol):
9
+ """Protocol for confirmation request handlers.
10
+
11
+ This protocol defines the interface that all confirmation handlers
12
+ must implement to be compatible with the CLI confirmation system.
13
+ """
14
+
15
+ async def handle_permission(self, request: PermissionRequest) -> bool:
16
+ """Handle a permission request.
17
+
18
+ Args:
19
+ request: The permission request to handle
20
+
21
+ Returns:
22
+ bool: True if the user confirmed, False otherwise
23
+ """
24
+ ...
25
+
26
+ def cancel_confirmation(self, request_id: str, reason: str = "cancelled") -> None:
27
+ """Cancel an ongoing confirmation request.
28
+
29
+ Args:
30
+ request_id: The ID of the request to cancel
31
+ reason: The reason for cancellation
32
+ """
33
+ ...