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,553 @@
1
+ """CLI command for handling confirmation requests via SSE stream."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import structlog
12
+ import typer
13
+ from structlog import get_logger
14
+
15
+ from ccproxy.api.services.permission_service import PermissionRequest
16
+ from ccproxy.api.ui.permission_handler_protocol import ConfirmationHandlerProtocol
17
+ from ccproxy.api.ui.terminal_permission_handler import (
18
+ TerminalPermissionHandler as TextualPermissionHandler,
19
+ )
20
+ from ccproxy.config.settings import get_settings
21
+
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ app = typer.Typer(
26
+ name="confirmation-handler",
27
+ help="Connect to the API server and handle confirmation requests",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ class SSEConfirmationHandler:
33
+ """Handles confirmation requests received via SSE stream."""
34
+
35
+ def __init__(
36
+ self,
37
+ api_url: str,
38
+ terminal_handler: ConfirmationHandlerProtocol,
39
+ ui: bool = True,
40
+ auth_token: str | None = None,
41
+ auto_reconnect: bool = True,
42
+ ):
43
+ self.api_url = api_url.rstrip("/")
44
+ self.terminal_handler = terminal_handler
45
+ self.client: httpx.AsyncClient | None = None
46
+ self.max_retries = 5
47
+ self.base_delay = 1.0
48
+ self.max_delay = 60.0
49
+ self.ui = ui
50
+ self.auth_token = auth_token
51
+ self.auto_reconnect = auto_reconnect
52
+
53
+ self._ongoing_requests: dict[str, asyncio.Task[bool]] = {}
54
+ self._resolved_requests: dict[str, tuple[bool, str]] = {}
55
+ self._resolved_by_us: set[str] = set()
56
+
57
+ async def __aenter__(self) -> "SSEConfirmationHandler":
58
+ """Async context manager entry."""
59
+ headers = {}
60
+ if self.auth_token:
61
+ headers["Authorization"] = f"Bearer {self.auth_token}"
62
+
63
+ self.client = httpx.AsyncClient(timeout=300.0, headers=headers) # 5 minutes
64
+ return self
65
+
66
+ async def __aexit__(
67
+ self,
68
+ exc_type: type[BaseException] | None,
69
+ exc_val: BaseException | None,
70
+ exc_tb: object,
71
+ ) -> None:
72
+ """Async context manager exit."""
73
+ if self.client:
74
+ await self.client.aclose()
75
+ self.client = None
76
+
77
+ async def handle_event(self, event_type: str, data: dict[str, Any]) -> None:
78
+ """Handle an SSE event by dispatching to specific handlers.
79
+
80
+ Args:
81
+ event_type: Type of the event
82
+ data: Event data
83
+ """
84
+ if event_type == "ping":
85
+ return
86
+
87
+ from ccproxy.models.permissions import EventType
88
+
89
+ handler_map = {
90
+ EventType.PERMISSION_REQUEST.value: self._handle_permission_request,
91
+ EventType.PERMISSION_RESOLVED.value: self._handle_permission_resolved,
92
+ }
93
+
94
+ handler = handler_map.get(event_type)
95
+ if handler:
96
+ await handler(data)
97
+ else:
98
+ logger.warning("unhandled_sse_event", event_type=event_type)
99
+
100
+ async def _handle_permission_request(self, data: dict[str, Any]) -> None:
101
+ """Handle a confirmation request event.
102
+
103
+ Args:
104
+ data: Event data containing request details
105
+ """
106
+ request_id = data.get("request_id")
107
+ if not request_id:
108
+ logger.warning("permission_request_missing_id", data=data)
109
+ return
110
+
111
+ # Check if this request was already resolved by another handler
112
+ if request_id in self._resolved_requests:
113
+ allowed, reason = self._resolved_requests[request_id]
114
+ logger.info(
115
+ "permission_already_resolved_by_other_handler",
116
+ request_id=request_id,
117
+ allowed=allowed,
118
+ reason=reason,
119
+ )
120
+ logger.info(
121
+ "permission_already_handled",
122
+ request_id=request_id[:8],
123
+ reason=reason,
124
+ )
125
+ return
126
+
127
+ logger.info(
128
+ "permission_request_received",
129
+ request_id=request_id,
130
+ tool_name=data.get("tool_name"),
131
+ )
132
+
133
+ try:
134
+ # Map request_id to id field for PermissionRequest model
135
+ request_data = dict(data)
136
+ if "request_id" in request_data:
137
+ request_data["id"] = request_data.pop("request_id")
138
+ request = PermissionRequest.model_validate(request_data)
139
+ except Exception as e:
140
+ logger.error(
141
+ "permission_request_validation_failed", data=data, error=str(e)
142
+ )
143
+ return
144
+
145
+ if self.ui and request_id is not None:
146
+ task = asyncio.create_task(
147
+ self._handle_permission_with_cancellation(request)
148
+ )
149
+ self._ongoing_requests[request_id] = task
150
+
151
+ async def _handle_permission_resolved(self, data: dict[str, Any]) -> None:
152
+ """Handle a confirmation resolved event.
153
+
154
+ Args:
155
+ data: Event data containing resolution details
156
+ """
157
+ request_id = data.get("request_id")
158
+ allowed = data.get("allowed", False)
159
+
160
+ if request_id is not None and allowed is not None:
161
+ reason = (
162
+ "approved by another handler"
163
+ if allowed
164
+ else "denied by another handler"
165
+ )
166
+ self._resolved_requests[request_id] = (allowed, reason)
167
+
168
+ was_resolved_by_us = (
169
+ request_id is not None and request_id in self._resolved_by_us
170
+ )
171
+
172
+ if request_id is not None and request_id in self._ongoing_requests:
173
+ task = self._ongoing_requests[request_id]
174
+ if not task.done() and not was_resolved_by_us:
175
+ logger.info(
176
+ "cancelling_ongoing_confirmation",
177
+ request_id=request_id,
178
+ allowed=allowed,
179
+ )
180
+
181
+ status_text = "approved" if allowed else "denied"
182
+ self.terminal_handler.cancel_confirmation(
183
+ request_id, f"{status_text} by another handler"
184
+ )
185
+
186
+ task.cancel()
187
+
188
+ with contextlib.suppress(TimeoutError, asyncio.CancelledError):
189
+ await asyncio.wait_for(task, timeout=0.1)
190
+
191
+ logger.info(
192
+ "permission_cancelled_by_other_handler",
193
+ request_id=request_id[:8],
194
+ status=status_text,
195
+ )
196
+
197
+ if request_id is not None:
198
+ self._ongoing_requests.pop(request_id, None)
199
+
200
+ if request_id is not None:
201
+ self._resolved_by_us.discard(request_id)
202
+
203
+ async def _handle_permission_with_cancellation(
204
+ self, request: PermissionRequest
205
+ ) -> bool:
206
+ """Handle permission with cancellation support.
207
+
208
+ Args:
209
+ request: The permission request to handle
210
+ """
211
+ try:
212
+ allowed = await self.terminal_handler.handle_permission(request)
213
+
214
+ if request.id in self._resolved_requests:
215
+ logger.info(
216
+ "permission_resolved_while_processing",
217
+ request_id=request.id,
218
+ our_result=allowed,
219
+ )
220
+ return False
221
+
222
+ self._resolved_by_us.add(request.id)
223
+
224
+ await self.send_response(request.id, allowed)
225
+
226
+ await asyncio.sleep(0.5)
227
+
228
+ return allowed
229
+
230
+ except asyncio.CancelledError:
231
+ logger.info(
232
+ "permission_cancelled",
233
+ request_id=request.id,
234
+ )
235
+ raise
236
+
237
+ except Exception as e:
238
+ logger.error(
239
+ "permission_handling_error",
240
+ request_id=request.id,
241
+ error=str(e),
242
+ exc_info=True,
243
+ )
244
+ # Only send response if not already resolved
245
+ if request.id not in self._resolved_requests:
246
+ # If response fails, it might already be resolved
247
+ with contextlib.suppress(Exception):
248
+ await self.send_response(request.id, False)
249
+ return False
250
+
251
+ async def send_response(self, request_id: str, allowed: bool) -> None:
252
+ """Send a confirmation response to the API.
253
+
254
+ Args:
255
+ request_id: ID of the confirmation request
256
+ allowed: Whether to allow or deny
257
+ """
258
+ if not self.client:
259
+ logger.error("send_response_no_client", request_id=request_id)
260
+ return
261
+
262
+ try:
263
+ response = await self.client.post(
264
+ f"{self.api_url}/permissions/{request_id}/respond",
265
+ json={"allowed": allowed},
266
+ )
267
+
268
+ if response.status_code == 200:
269
+ logger.info(
270
+ "permission_response_sent",
271
+ request_id=request_id,
272
+ allowed=allowed,
273
+ )
274
+ elif response.status_code == 409:
275
+ # Already resolved by another handler
276
+ logger.info(
277
+ "permission_already_resolved",
278
+ request_id=request_id,
279
+ status_code=response.status_code,
280
+ )
281
+ else:
282
+ logger.error(
283
+ "permission_response_failed",
284
+ request_id=request_id,
285
+ status_code=response.status_code,
286
+ response=response.text,
287
+ )
288
+
289
+ except Exception as e:
290
+ logger.error(
291
+ "permission_response_error",
292
+ request_id=request_id,
293
+ error=str(e),
294
+ exc_info=True,
295
+ )
296
+
297
+ async def parse_sse_stream(
298
+ self, response: httpx.Response
299
+ ) -> AsyncIterator[tuple[str, dict[str, Any]]]:
300
+ """Parse SSE events from the response stream.
301
+
302
+ Args:
303
+ response: The httpx response with streaming content
304
+
305
+ Yields:
306
+ Tuples of (event_type, data)
307
+ """
308
+ buffer = ""
309
+ async for chunk in response.aiter_text():
310
+ buffer += chunk
311
+
312
+ buffer = buffer.replace("\r\n", "\n")
313
+
314
+ while "\n\n" in buffer:
315
+ event_text, buffer = buffer.split("\n\n", 1)
316
+
317
+ if not event_text.strip():
318
+ continue
319
+
320
+ event_type = "message"
321
+ data_lines = []
322
+
323
+ for line in event_text.split("\n"):
324
+ line = line.strip()
325
+ if line.startswith("event:"):
326
+ event_type = line[6:].strip()
327
+ elif line.startswith("data:"):
328
+ data_lines.append(line[5:].strip())
329
+
330
+ if data_lines:
331
+ try:
332
+ data_json = " ".join(data_lines)
333
+ data = json.loads(data_json)
334
+ yield event_type, data
335
+ except json.JSONDecodeError as e:
336
+ logger.error(
337
+ "sse_parse_error",
338
+ event_type=event_type,
339
+ data=" ".join(data_lines),
340
+ error=str(e),
341
+ )
342
+
343
+ async def run(self) -> None:
344
+ """Run the SSE client with reconnection logic."""
345
+ if not self.client:
346
+ logger.error("run_no_client")
347
+ return
348
+
349
+ stream_url = f"{self.api_url}/permissions/stream"
350
+ retry_count = 0
351
+
352
+ logger.info(
353
+ "connecting_to_permission_stream",
354
+ url=stream_url,
355
+ )
356
+ print(f"Connecting to confirmation stream at {stream_url}...")
357
+
358
+ while retry_count <= self.max_retries:
359
+ try:
360
+ await self._connect_and_handle_stream(stream_url)
361
+ # If we get here, connection ended gracefully
362
+ if self.auto_reconnect:
363
+ # Reset retry count and reconnect
364
+ retry_count = 0
365
+ print("Connection closed. Reconnecting...")
366
+ await asyncio.sleep(1.0) # Brief pause before reconnecting
367
+ continue
368
+ else:
369
+ print("Connection closed. Exiting (auto-reconnect disabled).")
370
+ break
371
+
372
+ except KeyboardInterrupt:
373
+ logger.info("permission_handler_shutdown_requested")
374
+ break
375
+
376
+ except (
377
+ httpx.ConnectError,
378
+ httpx.TimeoutException,
379
+ httpx.ReadTimeout,
380
+ ) as e:
381
+ retry_count += 1
382
+ if retry_count > self.max_retries:
383
+ logger.error(
384
+ "connection_failed_max_retries",
385
+ max_retries=self.max_retries,
386
+ )
387
+ raise typer.Exit(1) from None
388
+
389
+ # Exponential backoff with jitter
390
+ delay = min(self.base_delay * (2 ** (retry_count - 1)), self.max_delay)
391
+
392
+ logger.warning(
393
+ "connection_failed_retrying",
394
+ attempt=retry_count,
395
+ max_retries=self.max_retries,
396
+ retry_delay=delay,
397
+ error=str(e),
398
+ )
399
+
400
+ print(
401
+ f"Connection failed (attempt {retry_count}/{self.max_retries}). Retrying in {delay}s..."
402
+ )
403
+
404
+ await asyncio.sleep(delay)
405
+ continue
406
+
407
+ except Exception as e:
408
+ logger.error("sse_client_error", error=str(e), exc_info=True)
409
+ raise typer.Exit(1) from e
410
+
411
+ async def _connect_and_handle_stream(self, stream_url: str) -> None:
412
+ """Connect to the stream and handle events."""
413
+ if not self.client:
414
+ logger.error("connect_no_client")
415
+ return
416
+
417
+ async with self.client.stream("GET", stream_url) as response:
418
+ if response.status_code != 200:
419
+ error_text = ""
420
+ try:
421
+ error_bytes = await response.aread()
422
+ error_text = error_bytes.decode("utf-8")
423
+ except Exception:
424
+ error_text = "Unable to read error response"
425
+
426
+ logger.error(
427
+ "sse_connection_failed",
428
+ status_code=response.status_code,
429
+ response=error_text,
430
+ )
431
+
432
+ if response.status_code in (502, 503, 504):
433
+ # Server errors - retry
434
+ raise httpx.ConnectError(
435
+ f"Server error: HTTP {response.status_code}"
436
+ )
437
+ else:
438
+ # Client errors - don't retry
439
+ logger.error(
440
+ "sse_connection_client_error",
441
+ status_code=response.status_code,
442
+ response=error_text,
443
+ )
444
+ raise typer.Exit(1)
445
+
446
+ logger.info(
447
+ "sse_connection_established",
448
+ url=stream_url,
449
+ message="Connected to confirmation stream. Waiting for requests...",
450
+ )
451
+ print("✓ Connected to confirmation stream. Waiting for requests...")
452
+
453
+ async for event_type, data in self.parse_sse_stream(response):
454
+ try:
455
+ await self.handle_event(event_type, data)
456
+ except Exception as e:
457
+ logger.error(
458
+ "sse_event_error",
459
+ event_type=event_type,
460
+ error=str(e),
461
+ exc_info=True,
462
+ )
463
+
464
+
465
+ @app.command()
466
+ def connect(
467
+ api_url: str | None = typer.Option(
468
+ None,
469
+ "--api-url",
470
+ "-u",
471
+ help="API server URL (defaults to settings)",
472
+ ),
473
+ no_ui: bool = typer.Option(False, "--no-ui", help="Disable UI mode"),
474
+ verbose: int = typer.Option(
475
+ 0,
476
+ "-v",
477
+ "--verbose",
478
+ count=True,
479
+ help="Increase verbosity (-v for INFO, -vv for DEBUG)",
480
+ ),
481
+ auth_token: str | None = typer.Option(
482
+ None,
483
+ "--auth-token",
484
+ "-t",
485
+ help="Bearer token for API authentication (overrides config)",
486
+ envvar="CCPROXY_AUTH_TOKEN",
487
+ ),
488
+ no_reconnect: bool = typer.Option(
489
+ False,
490
+ "--no-reconnect",
491
+ help="Disable automatic reconnection when connection is lost",
492
+ ),
493
+ ) -> None:
494
+ """Connect to the API server and handle confirmation requests.
495
+
496
+ This command connects to the CCProxy API server via Server-Sent Events
497
+ and handles permission confirmation requests in the terminal.
498
+
499
+ """
500
+ # Configure logging level based on verbosity
501
+ # Handle case where verbose might be OptionInfo (in tests) or int (runtime)
502
+ verbose_count = verbose if isinstance(verbose, int) else 0
503
+
504
+ if verbose_count >= 2:
505
+ log_level = logging.DEBUG
506
+ elif verbose_count >= 1:
507
+ log_level = logging.INFO
508
+ else:
509
+ log_level = logging.WARNING
510
+
511
+ # Configure root logger level
512
+ logging.basicConfig(level=log_level)
513
+
514
+ # Configure structlog to respect the same log level
515
+ structlog.configure(
516
+ wrapper_class=structlog.make_filtering_bound_logger(log_level),
517
+ )
518
+
519
+ settings = get_settings()
520
+
521
+ # Use provided URL or default from settings
522
+ if not api_url:
523
+ api_url = f"http://{settings.server.host}:{settings.server.port}"
524
+
525
+ # Determine auth token: CLI arg > config setting > None
526
+ token = auth_token or settings.security.auth_token
527
+
528
+ # Create handlers based on UI mode selection
529
+ terminal_handler: ConfirmationHandlerProtocol = TextualPermissionHandler()
530
+
531
+ async def run_handler() -> None:
532
+ """Run the handler with proper resource management."""
533
+ async with SSEConfirmationHandler(
534
+ api_url,
535
+ terminal_handler,
536
+ not no_ui,
537
+ auth_token=token,
538
+ auto_reconnect=not no_reconnect,
539
+ ) as sse_handler:
540
+ await sse_handler.run()
541
+
542
+ # Run the async handler
543
+ try:
544
+ asyncio.run(run_handler())
545
+ except KeyboardInterrupt:
546
+ logger.info("permission_handler_stopped")
547
+ except Exception as e:
548
+ logger.error("permission_handler_error", error=str(e), exc_info=True)
549
+ raise typer.Exit(1) from e
550
+
551
+
552
+ if __name__ == "__main__":
553
+ app()
@@ -10,7 +10,6 @@ import uvicorn
10
10
  from click import get_current_context
11
11
  from structlog import get_logger
12
12
 
13
- from ccproxy._version import __version__
14
13
  from ccproxy.cli.helpers import (
15
14
  get_rich_toolkit,
16
15
  is_running_in_docker,
@@ -36,6 +35,7 @@ from ..options.claude_options import (
36
35
  validate_max_thinking_tokens,
37
36
  validate_max_turns,
38
37
  validate_permission_mode,
38
+ validate_sdk_message_mode,
39
39
  )
40
40
  from ..options.security_options import SecurityOptions, validate_auth_token
41
41
  from ..options.server_options import (
@@ -45,10 +45,6 @@ from ..options.server_options import (
45
45
  )
46
46
 
47
47
 
48
- # Logger will be configured by configuration manager
49
- logger = get_logger(__name__)
50
-
51
-
52
48
  def get_config_path_from_context() -> Path | None:
53
49
  """Get config path from typer context if available."""
54
50
  try:
@@ -328,7 +324,7 @@ def api(
328
324
  str | None,
329
325
  typer.Option(
330
326
  "--log-level",
331
- help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
327
+ help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Use WARNING for minimal output.",
332
328
  callback=validate_log_level,
333
329
  rich_help_panel="Server Settings",
334
330
  ),
@@ -341,6 +337,14 @@ def api(
341
337
  rich_help_panel="Server Settings",
342
338
  ),
343
339
  ] = None,
340
+ use_terminal_permission_handler: Annotated[
341
+ bool,
342
+ typer.Option(
343
+ "--terminal-permission-handler",
344
+ help="Enable terminal permission terminal handler",
345
+ rich_help_panel="Server Settings",
346
+ ),
347
+ ] = False,
344
348
  # Security options
345
349
  auth_token: Annotated[
346
350
  str | None,
@@ -429,6 +433,15 @@ def api(
429
433
  rich_help_panel="Claude Settings",
430
434
  ),
431
435
  ] = None,
436
+ sdk_message_mode: Annotated[
437
+ str | None,
438
+ typer.Option(
439
+ "--sdk-message-mode",
440
+ help="SDK message handling mode: forward (direct SDK blocks), ignore (skip blocks), formatted (XML tags with JSON data)",
441
+ callback=validate_sdk_message_mode,
442
+ rich_help_panel="Claude Settings",
443
+ ),
444
+ ] = None,
432
445
  # Core settings
433
446
  docker: Annotated[
434
447
  bool,
@@ -546,6 +559,7 @@ def api(
546
559
  reload=reload,
547
560
  log_level=log_level,
548
561
  log_file=log_file,
562
+ use_terminal_confirmation_handler=use_terminal_permission_handler,
549
563
  )
550
564
 
551
565
  claude_options = ClaudeOptions(
@@ -558,6 +572,7 @@ def api(
558
572
  max_turns=max_turns,
559
573
  cwd=cwd,
560
574
  permission_prompt_tool_name=permission_prompt_tool_name,
575
+ sdk_message_mode=sdk_message_mode,
561
576
  )
562
577
 
563
578
  security_options = SecurityOptions(auth_token=auth_token)
@@ -570,6 +585,7 @@ def api(
570
585
  reload=server_options.reload,
571
586
  log_level=server_options.log_level,
572
587
  log_file=server_options.log_file,
588
+ use_terminal_confirmation_handler=server_options.use_terminal_confirmation_handler,
573
589
  # Security options
574
590
  auth_token=security_options.auth_token,
575
591
  # Claude options
@@ -582,6 +598,7 @@ def api(
582
598
  max_turns=claude_options.max_turns,
583
599
  permission_prompt_tool_name=claude_options.permission_prompt_tool_name,
584
600
  cwd=claude_options.cwd,
601
+ sdk_message_mode=claude_options.sdk_message_mode,
585
602
  )
586
603
 
587
604
  # Load settings with CLI overrides
@@ -591,17 +608,16 @@ def api(
591
608
 
592
609
  # Set up logging once with the effective log level
593
610
  # Import here to avoid circular import
594
- import structlog
595
611
 
596
612
  from ccproxy.core.logging import setup_logging
597
613
 
598
614
  # Always reconfigure logging to ensure log level changes are picked up
599
615
  # Use JSON logs if explicitly requested via env var
600
- json_logs = os.environ.get("CCPROXY_JSON_LOGS", "").lower() == "true"
616
+ print(f"{settings.server.log_level} {settings.server.log_file}")
601
617
  setup_logging(
602
- json_logs=json_logs,
603
- log_level=server_options.log_level or settings.server.log_level,
604
- log_file=server_options.log_file or settings.server.log_file,
618
+ json_logs=settings.server.log_format == "json",
619
+ log_level_name=settings.server.log_level,
620
+ log_file=settings.server.log_file,
605
621
  )
606
622
 
607
623
  # Re-get logger after logging is configured
@@ -624,7 +640,7 @@ def api(
624
640
  )
625
641
 
626
642
  # Log effective configuration
627
- logger.info(
643
+ logger.debug(
628
644
  "configuration_loaded",
629
645
  host=settings.server.host,
630
646
  port=settings.server.port,
@@ -778,6 +794,8 @@ def claude(
778
794
  toolkit = get_rich_toolkit()
779
795
 
780
796
  try:
797
+ # Logger will be configured by configuration manager
798
+ logger = get_logger(__name__)
781
799
  # Log CLI command execution start
782
800
  logger.info(
783
801
  "cli_command_starting",
@@ -82,8 +82,6 @@ def validate_docker_home(
82
82
  if value is None:
83
83
  return None
84
84
 
85
- from pathlib import Path
86
-
87
85
  from ccproxy.config.docker_settings import validate_host_path
88
86
 
89
87
  try:
@@ -116,8 +114,6 @@ def validate_docker_workspace(
116
114
  if value is None:
117
115
  return None
118
116
 
119
- from pathlib import Path
120
-
121
117
  from ccproxy.config.docker_settings import validate_host_path
122
118
 
123
119
  try: