ccproxy-api 0.1.2__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 (108) 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 +62 -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 +76 -29
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -2
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +30 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +5 -16
  45. ccproxy/cli/options/claude_options.py +19 -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 +13 -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 +29 -2
  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 +220 -328
  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.2.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/cli/commands/permission.py +0 -128
  104. ccproxy_api-0.1.2.dist-info/RECORD +0 -150
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +0 -0
  108. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,593 @@
1
+ """Terminal UI handler for confirmation requests using Textual with request stacking support."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ from structlog import get_logger
9
+ from textual.app import App, ComposeResult
10
+ from textual.containers import Container, Vertical
11
+ from textual.events import Key
12
+ from textual.reactive import reactive
13
+ from textual.screen import ModalScreen
14
+ from textual.timer import Timer
15
+ from textual.widgets import Label, Static
16
+
17
+ from ccproxy.api.services.permission_service import PermissionRequest
18
+
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class PendingRequest:
25
+ """Represents a pending confirmation request with its response future."""
26
+
27
+ request: PermissionRequest
28
+ future: asyncio.Future[bool]
29
+ cancelled: bool = False
30
+
31
+
32
+ class ConfirmationScreen(ModalScreen[bool]):
33
+ """Modal screen for displaying a single confirmation request."""
34
+
35
+ BINDINGS = [
36
+ ("y", "confirm", "Yes"),
37
+ ("n", "deny", "No"),
38
+ ("enter", "confirm", "Confirm"),
39
+ ("escape", "deny", "Cancel"),
40
+ ("ctrl+c", "cancel", "Cancel"),
41
+ ]
42
+
43
+ def __init__(self, request: PermissionRequest) -> None:
44
+ super().__init__()
45
+ self.request = request
46
+ self.start_time = time.time()
47
+ self.countdown_timer: Timer | None = None
48
+
49
+ time_remaining = reactive(0.0)
50
+
51
+ def compose(self) -> ComposeResult:
52
+ """Compose the confirmation dialog."""
53
+ with Container(id="confirmation-dialog"):
54
+ yield Vertical(
55
+ Label("[bold red]Permission Request[/bold red]", id="title"),
56
+ self._create_info_display(),
57
+ Label("Calculating timeout...", id="countdown", classes="countdown"),
58
+ Label(
59
+ "[bold white]Allow this operation? (y/N):[/bold white]",
60
+ id="question",
61
+ ),
62
+ id="content",
63
+ )
64
+
65
+ def _create_info_display(self) -> Static:
66
+ """Create the information display widget."""
67
+ info_lines = [
68
+ f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
69
+ f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
70
+ ]
71
+
72
+ # Add input parameters
73
+ for key, value in self.request.input.items():
74
+ display_value = value if len(value) <= 50 else f"{value[:47]}..."
75
+ info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
76
+
77
+ return Static("\n".join(info_lines), id="info")
78
+
79
+ def on_mount(self) -> None:
80
+ """Start the countdown timer when mounted."""
81
+ self.update_countdown()
82
+ self.countdown_timer = self.set_interval(0.1, self.update_countdown)
83
+
84
+ def update_countdown(self) -> None:
85
+ """Update the countdown display."""
86
+ elapsed = time.time() - self.start_time
87
+ remaining = max(0, self.request.time_remaining() - elapsed)
88
+ self.time_remaining = remaining
89
+
90
+ if remaining <= 0:
91
+ self._timeout()
92
+ else:
93
+ countdown_widget = self.query_one("#countdown", Label)
94
+ if remaining > 10:
95
+ style = "yellow"
96
+ elif remaining > 5:
97
+ style = "orange1"
98
+ else:
99
+ style = "red"
100
+ countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
101
+
102
+ def _timeout(self) -> None:
103
+ """Handle timeout."""
104
+ if self.countdown_timer:
105
+ self.countdown_timer.stop()
106
+ self.countdown_timer = None
107
+ # Schedule the async result display
108
+ self.call_later(self._show_result, False, "TIMEOUT - DENIED")
109
+
110
+ async def _show_result(self, allowed: bool, message: str) -> None:
111
+ """Show the result with visual feedback before dismissing.
112
+
113
+ Args:
114
+ allowed: Whether the request was allowed
115
+ message: Message to display
116
+ """
117
+ # Update the question to show the result
118
+ question_widget = self.query_one("#question", Label)
119
+ if allowed:
120
+ question_widget.update(f"[bold green]✓ {message}[/bold green]")
121
+ else:
122
+ question_widget.update(f"[bold red]✗ {message}[/bold red]")
123
+
124
+ # Update the dialog border color
125
+ dialog = self.query_one("#confirmation-dialog", Container)
126
+ if allowed:
127
+ dialog.styles.border = ("solid", "green")
128
+ else:
129
+ dialog.styles.border = ("solid", "red")
130
+
131
+ # Give user time to see the result
132
+ await asyncio.sleep(1.5)
133
+ self.dismiss(allowed)
134
+
135
+ def action_confirm(self) -> None:
136
+ """Confirm the request."""
137
+ if self.countdown_timer:
138
+ self.countdown_timer.stop()
139
+ self.countdown_timer = None
140
+ self.call_later(self._show_result, True, "ALLOWED")
141
+
142
+ def action_deny(self) -> None:
143
+ """Deny the request."""
144
+ if self.countdown_timer:
145
+ self.countdown_timer.stop()
146
+ self.countdown_timer = None
147
+ self.call_later(self._show_result, False, "DENIED")
148
+
149
+ def action_cancel(self) -> None:
150
+ """Cancel the request (Ctrl+C)."""
151
+ if self.countdown_timer:
152
+ self.countdown_timer.stop()
153
+ self.countdown_timer = None
154
+ self.call_later(self._show_result, False, "CANCELLED")
155
+ # Raise KeyboardInterrupt to forward it up
156
+ raise KeyboardInterrupt("User cancelled confirmation")
157
+
158
+
159
+ class ConfirmationApp(App[bool]):
160
+ """Simple Textual app for a single confirmation request."""
161
+
162
+ CSS = """
163
+
164
+ Screen {
165
+ border: none;
166
+ }
167
+
168
+ Static {
169
+ background: $surface;
170
+ }
171
+
172
+ #confirmation-dialog {
173
+ width: 60;
174
+ height: 18;
175
+ border: round solid $accent;
176
+ background: $surface;
177
+ padding: 1;
178
+ box-sizing: border-box;
179
+ }
180
+
181
+ #title {
182
+ text-align: center;
183
+ margin-bottom: 1;
184
+ }
185
+
186
+ #info {
187
+ border: solid $primary;
188
+ margin: 1;
189
+ padding: 1;
190
+ background: $surface;
191
+ height: auto;
192
+ }
193
+
194
+ #countdown {
195
+ text-align: center;
196
+ margin: 1;
197
+ background: $surface;
198
+ text-style: bold;
199
+ height: 1;
200
+ }
201
+
202
+ #question {
203
+ text-align: center;
204
+ margin: 1;
205
+ background: $surface;
206
+ }
207
+
208
+
209
+ .countdown {
210
+ text-style: bold;
211
+ }
212
+ """
213
+
214
+ BINDINGS = [
215
+ ("y", "confirm", "Yes"),
216
+ ("n", "deny", "No"),
217
+ ("enter", "confirm", "Confirm"),
218
+ ("escape", "deny", "Cancel"),
219
+ ("ctrl+c", "cancel", "Cancel"),
220
+ ]
221
+
222
+ def __init__(self, request: PermissionRequest) -> None:
223
+ super().__init__()
224
+ self.theme = "textual-ansi"
225
+ self.request = request
226
+ self.result = False
227
+ self.start_time = time.time()
228
+ self.countdown_timer: Timer | None = None
229
+
230
+ time_remaining = reactive(0.0)
231
+
232
+ def compose(self) -> ComposeResult:
233
+ """Compose the confirmation dialog directly."""
234
+ with Container(id="confirmation-dialog"):
235
+ yield Vertical(
236
+ Label("[bold red]Permission Request[/bold red]", id="title"),
237
+ self._create_info_display(),
238
+ Label("Calculating timeout...", id="countdown", classes="countdown"),
239
+ Label(
240
+ "[bold white]Allow this operation? (y/N):[/bold white]",
241
+ id="question",
242
+ ),
243
+ id="content",
244
+ )
245
+
246
+ def _create_info_display(self) -> Static:
247
+ """Create the information display widget."""
248
+ info_lines = [
249
+ f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
250
+ f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
251
+ ]
252
+
253
+ # Add input parameters
254
+ for key, value in self.request.input.items():
255
+ display_value = value if len(value) <= 50 else f"{value[:47]}..."
256
+ info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
257
+
258
+ return Static("\n".join(info_lines), id="info")
259
+
260
+ def on_mount(self) -> None:
261
+ """Start the countdown timer when mounted."""
262
+ self.update_countdown()
263
+ self.countdown_timer = self.set_interval(0.1, self.update_countdown)
264
+
265
+ def update_countdown(self) -> None:
266
+ """Update the countdown display."""
267
+ elapsed = time.time() - self.start_time
268
+ remaining = max(0, self.request.time_remaining() - elapsed)
269
+ self.time_remaining = remaining
270
+
271
+ if remaining <= 0:
272
+ self._timeout()
273
+ else:
274
+ countdown_widget = self.query_one("#countdown", Label)
275
+ if remaining > 10:
276
+ style = "yellow"
277
+ elif remaining > 5:
278
+ style = "orange1"
279
+ else:
280
+ style = "red"
281
+ countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
282
+
283
+ def _timeout(self) -> None:
284
+ """Handle timeout."""
285
+ if self.countdown_timer:
286
+ self.countdown_timer.stop()
287
+ self.countdown_timer = None
288
+ # Schedule the async result display
289
+ self.call_later(self._show_result, False, "TIMEOUT - DENIED")
290
+
291
+ async def _show_result(self, allowed: bool, message: str) -> None:
292
+ """Show the result with visual feedback before exiting.
293
+
294
+ Args:
295
+ allowed: Whether the request was allowed
296
+ message: Message to display
297
+ """
298
+ # Update the question to show the result
299
+ question_widget = self.query_one("#question", Label)
300
+ if allowed:
301
+ question_widget.update(f"[bold green]✓ {message}[/bold green]")
302
+ else:
303
+ question_widget.update(f"[bold red]✗ {message}[/bold red]")
304
+
305
+ # Update the dialog border color
306
+ dialog = self.query_one("#confirmation-dialog", Container)
307
+ if allowed:
308
+ dialog.styles.border = ("solid", "green")
309
+ else:
310
+ dialog.styles.border = ("solid", "red")
311
+
312
+ # Give user time to see the result
313
+ await asyncio.sleep(1.5)
314
+ self.exit(allowed)
315
+
316
+ def action_confirm(self) -> None:
317
+ """Confirm the request."""
318
+ if self.countdown_timer:
319
+ self.countdown_timer.stop()
320
+ self.countdown_timer = None
321
+ self.call_later(self._show_result, True, "ALLOWED")
322
+
323
+ def action_deny(self) -> None:
324
+ """Deny the request."""
325
+ if self.countdown_timer:
326
+ self.countdown_timer.stop()
327
+ self.countdown_timer = None
328
+ self.call_later(self._show_result, False, "DENIED")
329
+
330
+ def action_cancel(self) -> None:
331
+ """Cancel the request (Ctrl+C)."""
332
+ if self.countdown_timer:
333
+ self.countdown_timer.stop()
334
+ self.countdown_timer = None
335
+ self.call_later(self._show_result, False, "CANCELLED")
336
+ # Raise KeyboardInterrupt to forward it up
337
+ raise KeyboardInterrupt("User cancelled confirmation")
338
+
339
+ async def on_key(self, event: Key) -> None:
340
+ """Handle global key events, especially Ctrl+C."""
341
+ if event.key == "ctrl+c":
342
+ # Forward the KeyboardInterrupt
343
+ self.exit(False)
344
+ raise KeyboardInterrupt("User cancelled confirmation")
345
+
346
+
347
+ class TerminalPermissionHandler:
348
+ """Handles confirmation requests in the terminal using Textual with request stacking.
349
+
350
+ Implements ConfirmationHandlerProtocol for type safety and interoperability.
351
+ """
352
+
353
+ def __init__(self) -> None:
354
+ """Initialize the terminal confirmation handler."""
355
+ self._request_queue: (
356
+ asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]] | None
357
+ ) = None
358
+ self._cancelled_requests: set[str] = set()
359
+ self._processing_task: asyncio.Task[None] | None = None
360
+ self._active_apps: dict[str, ConfirmationApp] = {}
361
+
362
+ def _get_request_queue(
363
+ self,
364
+ ) -> asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]]:
365
+ """Lazily initialize and return the request queue."""
366
+ if self._request_queue is None:
367
+ self._request_queue = asyncio.Queue()
368
+ return self._request_queue
369
+
370
+ def _safe_set_future_result(
371
+ self, future: asyncio.Future[bool], result: bool
372
+ ) -> bool:
373
+ """Safely set a future result, handling already cancelled futures.
374
+
375
+ Args:
376
+ future: The future to set the result on
377
+ result: The result to set
378
+
379
+ Returns:
380
+ bool: True if result was set successfully, False if future was cancelled
381
+ """
382
+ if future.cancelled():
383
+ return False
384
+ try:
385
+ future.set_result(result)
386
+ return True
387
+ except asyncio.InvalidStateError:
388
+ # Future was already resolved or cancelled
389
+ return False
390
+
391
+ def _safe_set_future_exception(
392
+ self, future: asyncio.Future[bool], exception: BaseException
393
+ ) -> bool:
394
+ """Safely set a future exception, handling already cancelled futures.
395
+
396
+ Args:
397
+ future: The future to set the exception on
398
+ exception: The exception to set
399
+
400
+ Returns:
401
+ bool: True if exception was set successfully, False if future was cancelled
402
+ """
403
+ if future.cancelled():
404
+ return False
405
+ try:
406
+ future.set_exception(exception)
407
+ return True
408
+ except asyncio.InvalidStateError:
409
+ # Future was already resolved or cancelled
410
+ return False
411
+
412
+ async def _process_queue(self) -> None:
413
+ """Process requests from the queue one by one."""
414
+ while True:
415
+ try:
416
+ request, future = await self._get_request_queue().get()
417
+
418
+ # Check if request is valid for processing
419
+ if not self._is_request_processable(request, future):
420
+ continue
421
+
422
+ # Process the request
423
+ await self._process_single_request(request, future)
424
+
425
+ except asyncio.CancelledError:
426
+ break
427
+ except Exception as e:
428
+ logger.error("queue_processing_error", error=str(e), exc_info=True)
429
+
430
+ def _is_request_processable(
431
+ self, request: PermissionRequest, future: asyncio.Future[bool]
432
+ ) -> bool:
433
+ """Check if a request can be processed."""
434
+ # Check if cancelled before processing
435
+ if request.id in self._cancelled_requests:
436
+ self._safe_set_future_result(future, False)
437
+ self._cancelled_requests.discard(request.id)
438
+ return False
439
+
440
+ # Check if expired
441
+ if request.time_remaining() <= 0:
442
+ self._safe_set_future_result(future, False)
443
+ return False
444
+
445
+ return True
446
+
447
+ async def _process_single_request(
448
+ self, request: PermissionRequest, future: asyncio.Future[bool]
449
+ ) -> None:
450
+ """Process a single permission request."""
451
+ app = None
452
+ try:
453
+ # Create and run a simple app for this request
454
+ app = ConfirmationApp(request)
455
+ self._active_apps[request.id] = app
456
+
457
+ app_result = await app.run_async(inline=True, inline_no_clear=True)
458
+ result = bool(app_result) if app_result is not None else False
459
+
460
+ # Apply cancellation if it occurred during processing
461
+ if request.id in self._cancelled_requests:
462
+ result = False
463
+ self._cancelled_requests.discard(request.id)
464
+
465
+ self._safe_set_future_result(future, result)
466
+
467
+ except KeyboardInterrupt:
468
+ self._safe_set_future_exception(
469
+ future, KeyboardInterrupt("User cancelled confirmation")
470
+ )
471
+ except Exception as e:
472
+ logger.error(
473
+ "confirmation_app_error",
474
+ request_id=request.id,
475
+ error=str(e),
476
+ exc_info=True,
477
+ )
478
+ self._safe_set_future_result(future, False)
479
+ finally:
480
+ # Always cleanup app reference
481
+ if app:
482
+ self._active_apps.pop(request.id, None)
483
+
484
+ def _ensure_processing_task_running(self) -> None:
485
+ """Ensure the processing task is running."""
486
+ if self._processing_task is None or self._processing_task.done():
487
+ self._processing_task = asyncio.create_task(self._process_queue())
488
+
489
+ async def _queue_and_wait_for_result(self, request: PermissionRequest) -> bool:
490
+ """Queue a request and wait for its result."""
491
+ future: asyncio.Future[bool] = asyncio.Future()
492
+ await self._get_request_queue().put((request, future))
493
+ return await future
494
+
495
+ async def handle_permission(self, request: PermissionRequest) -> bool:
496
+ """Handle a permission request.
497
+
498
+ Args:
499
+ request: The permission request to handle
500
+
501
+ Returns:
502
+ bool: True if the user confirmed, False otherwise
503
+ """
504
+ try:
505
+ logger.info(
506
+ "handling_confirmation_request",
507
+ request_id=request.id,
508
+ tool_name=request.tool_name,
509
+ time_remaining=request.time_remaining(),
510
+ )
511
+
512
+ # Check if request has already expired
513
+ if request.time_remaining() <= 0:
514
+ logger.info("confirmation_request_expired", request_id=request.id)
515
+ return False
516
+
517
+ # Ensure processing task is running
518
+ self._ensure_processing_task_running()
519
+
520
+ # Queue request and wait for result
521
+ result = await self._queue_and_wait_for_result(request)
522
+
523
+ logger.info(
524
+ "confirmation_request_completed", request_id=request.id, result=result
525
+ )
526
+
527
+ return result
528
+
529
+ except Exception as e:
530
+ logger.error(
531
+ "confirmation_handling_error",
532
+ request_id=request.id,
533
+ error=str(e),
534
+ exc_info=True,
535
+ )
536
+ return False
537
+
538
+ def cancel_confirmation(self, request_id: str, reason: str = "cancelled") -> None:
539
+ """Cancel an ongoing confirmation request.
540
+
541
+ Args:
542
+ request_id: The ID of the request to cancel
543
+ reason: The reason for cancellation
544
+ """
545
+ logger.info("cancelling_confirmation", request_id=request_id, reason=reason)
546
+ self._cancelled_requests.add(request_id)
547
+
548
+ # If there's an active dialog for this request, close it immediately
549
+ if request_id in self._active_apps:
550
+ app = self._active_apps[request_id]
551
+ # Schedule the cancellation feedback asynchronously
552
+ asyncio.create_task(self._cancel_active_dialog(app, reason))
553
+
554
+ async def _cancel_active_dialog(self, app: ConfirmationApp, reason: str) -> None:
555
+ """Cancel an active dialog with visual feedback.
556
+
557
+ Args:
558
+ app: The active ConfirmationApp to cancel
559
+ reason: The reason for cancellation
560
+ """
561
+ try:
562
+ # Determine the message and result based on reason
563
+ if "approved by another handler" in reason.lower():
564
+ message = "APPROVED BY ANOTHER HANDLER"
565
+ allowed = True
566
+ elif "denied by another handler" in reason.lower():
567
+ message = "DENIED BY ANOTHER HANDLER"
568
+ allowed = False
569
+ else:
570
+ message = f"CANCELLED - {reason.upper()}"
571
+ allowed = False
572
+
573
+ # Show visual feedback through the app's _show_result method
574
+ await app._show_result(allowed, message)
575
+
576
+ except Exception as e:
577
+ logger.error(
578
+ "cancel_dialog_error",
579
+ error=str(e),
580
+ exc_info=True,
581
+ )
582
+ # Fallback: just exit the app without feedback
583
+ with contextlib.suppress(Exception):
584
+ app.exit(False)
585
+
586
+ async def shutdown(self) -> None:
587
+ """Shutdown the handler and cleanup resources."""
588
+ if self._processing_task and not self._processing_task.done():
589
+ self._processing_task.cancel()
590
+ with contextlib.suppress(asyncio.CancelledError):
591
+ await self._processing_task
592
+
593
+ self._processing_task = None
@@ -5,10 +5,10 @@ from typing import Annotated
5
5
  from fastapi import Depends, HTTPException, Request, status
6
6
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
7
 
8
+ from ccproxy.api.dependencies import SettingsDep
8
9
  from ccproxy.auth.bearer import BearerTokenAuthManager
9
10
  from ccproxy.auth.exceptions import AuthenticationError
10
11
  from ccproxy.auth.manager import AuthManager
11
- from ccproxy.config.settings import Settings, get_settings
12
12
 
13
13
 
14
14
  # FastAPI security scheme for bearer tokens
@@ -17,10 +17,10 @@ bearer_scheme = HTTPBearer(auto_error=False)
17
17
 
18
18
  async def get_conditional_auth_manager(
19
19
  request: Request,
20
+ settings: SettingsDep,
20
21
  credentials: Annotated[
21
22
  HTTPAuthorizationCredentials | None, Depends(bearer_scheme)
22
23
  ] = None,
23
- settings: Annotated[Settings | None, Depends(get_settings)] = None,
24
24
  ) -> AuthManager | None:
25
25
  """Get authentication manager only if auth is required.
26
26
 
@@ -1,6 +1,6 @@
1
1
  """FastAPI dependency injection for authentication."""
2
2
 
3
- from typing import TYPE_CHECKING, Annotated, Any
3
+ from typing import TYPE_CHECKING, Annotated
4
4
 
5
5
  from fastapi import Depends, HTTPException, status
6
6
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -1,7 +1,6 @@
1
1
  """OAuth-specific models for authentication."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Optional
5
4
 
6
5
  from pydantic import BaseModel, Field
7
6
 
@@ -1,11 +1,9 @@
1
1
  """OAuth authentication routes for Anthropic OAuth login."""
2
2
 
3
- import asyncio
4
- import logging
5
3
  from pathlib import Path
6
4
  from typing import Any
7
5
 
8
- from fastapi import APIRouter, HTTPException, Query, Request
6
+ from fastapi import APIRouter, Query, Request
9
7
  from fastapi.responses import HTMLResponse
10
8
  from structlog import get_logger
11
9
 
@@ -3,7 +3,6 @@
3
3
  import contextlib
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Optional
7
6
 
8
7
  from structlog import get_logger
9
8
 
@@ -1,13 +1,10 @@
1
1
  """OS keyring storage implementation for token storage."""
2
2
 
3
3
  import json
4
- from typing import Optional
5
4
 
6
- import keyring
7
5
  from structlog import get_logger
8
6
 
9
7
  from ccproxy.auth.exceptions import (
10
- CredentialsInvalidError,
11
8
  CredentialsStorageError,
12
9
  )
13
10
  from ccproxy.auth.models import ClaudeCredentials
@@ -8,6 +8,7 @@ from .client import (
8
8
  )
9
9
  from .converter import MessageConverter
10
10
  from .options import OptionsHandler
11
+ from .parser import parse_formatted_sdk_content
11
12
 
12
13
 
13
14
  __all__ = [
@@ -17,4 +18,5 @@ __all__ = [
17
18
  "ClaudeSDKProcessError",
18
19
  "MessageConverter",
19
20
  "OptionsHandler",
21
+ "parse_formatted_sdk_content",
20
22
  ]