ccproxy-api 0.1.4__py3-none-any.whl → 0.1.6__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 (72) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/codex/__init__.py +11 -0
  3. ccproxy/adapters/openai/adapter.py +1 -1
  4. ccproxy/adapters/openai/models.py +1 -1
  5. ccproxy/adapters/openai/response_adapter.py +355 -0
  6. ccproxy/adapters/openai/response_models.py +178 -0
  7. ccproxy/adapters/openai/streaming.py +1 -0
  8. ccproxy/api/app.py +150 -224
  9. ccproxy/api/dependencies.py +22 -2
  10. ccproxy/api/middleware/errors.py +27 -3
  11. ccproxy/api/middleware/logging.py +4 -0
  12. ccproxy/api/responses.py +6 -1
  13. ccproxy/api/routes/claude.py +222 -17
  14. ccproxy/api/routes/codex.py +1231 -0
  15. ccproxy/api/routes/health.py +228 -3
  16. ccproxy/api/routes/proxy.py +25 -6
  17. ccproxy/api/services/permission_service.py +2 -2
  18. ccproxy/auth/openai/__init__.py +13 -0
  19. ccproxy/auth/openai/credentials.py +166 -0
  20. ccproxy/auth/openai/oauth_client.py +334 -0
  21. ccproxy/auth/openai/storage.py +184 -0
  22. ccproxy/claude_sdk/__init__.py +4 -8
  23. ccproxy/claude_sdk/client.py +661 -131
  24. ccproxy/claude_sdk/exceptions.py +16 -0
  25. ccproxy/claude_sdk/manager.py +219 -0
  26. ccproxy/claude_sdk/message_queue.py +342 -0
  27. ccproxy/claude_sdk/options.py +6 -1
  28. ccproxy/claude_sdk/session_client.py +546 -0
  29. ccproxy/claude_sdk/session_pool.py +550 -0
  30. ccproxy/claude_sdk/stream_handle.py +538 -0
  31. ccproxy/claude_sdk/stream_worker.py +392 -0
  32. ccproxy/claude_sdk/streaming.py +53 -11
  33. ccproxy/cli/commands/auth.py +398 -1
  34. ccproxy/cli/commands/serve.py +99 -1
  35. ccproxy/cli/options/claude_options.py +47 -0
  36. ccproxy/config/__init__.py +0 -3
  37. ccproxy/config/claude.py +171 -23
  38. ccproxy/config/codex.py +100 -0
  39. ccproxy/config/discovery.py +10 -1
  40. ccproxy/config/scheduler.py +2 -2
  41. ccproxy/config/settings.py +38 -1
  42. ccproxy/core/codex_transformers.py +389 -0
  43. ccproxy/core/http_transformers.py +458 -75
  44. ccproxy/core/logging.py +108 -12
  45. ccproxy/core/transformers.py +5 -0
  46. ccproxy/models/claude_sdk.py +57 -0
  47. ccproxy/models/detection.py +208 -0
  48. ccproxy/models/requests.py +22 -0
  49. ccproxy/models/responses.py +16 -0
  50. ccproxy/observability/access_logger.py +72 -14
  51. ccproxy/observability/metrics.py +151 -0
  52. ccproxy/observability/storage/duckdb_simple.py +12 -0
  53. ccproxy/observability/storage/models.py +16 -0
  54. ccproxy/observability/streaming_response.py +107 -0
  55. ccproxy/scheduler/manager.py +31 -6
  56. ccproxy/scheduler/tasks.py +122 -0
  57. ccproxy/services/claude_detection_service.py +269 -0
  58. ccproxy/services/claude_sdk_service.py +333 -130
  59. ccproxy/services/codex_detection_service.py +263 -0
  60. ccproxy/services/proxy_service.py +618 -197
  61. ccproxy/utils/__init__.py +9 -1
  62. ccproxy/utils/disconnection_monitor.py +83 -0
  63. ccproxy/utils/id_generator.py +12 -0
  64. ccproxy/utils/model_mapping.py +7 -5
  65. ccproxy/utils/startup_helpers.py +470 -0
  66. ccproxy_api-0.1.6.dist-info/METADATA +615 -0
  67. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
  68. ccproxy/config/loader.py +0 -105
  69. ccproxy_api-0.1.4.dist-info/METADATA +0 -369
  70. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
  71. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
  72. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/licenses/LICENSE +0 -0
ccproxy/utils/__init__.py CHANGED
@@ -1,6 +1,14 @@
1
1
  """Utility modules for shared functionality across the application."""
2
2
 
3
3
  from .cost_calculator import calculate_cost_breakdown, calculate_token_cost
4
+ from .disconnection_monitor import monitor_disconnection, monitor_stuck_stream
5
+ from .id_generator import generate_client_id
4
6
 
5
7
 
6
- __all__ = ["calculate_token_cost", "calculate_cost_breakdown"]
8
+ __all__ = [
9
+ "calculate_token_cost",
10
+ "calculate_cost_breakdown",
11
+ "monitor_disconnection",
12
+ "monitor_stuck_stream",
13
+ "generate_client_id",
14
+ ]
@@ -0,0 +1,83 @@
1
+ """Utility functions for monitoring client disconnection and stuck streams during streaming responses."""
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING
5
+
6
+ import structlog
7
+ from starlette.requests import Request
8
+
9
+
10
+ if TYPE_CHECKING:
11
+ from ccproxy.services.claude_sdk_service import ClaudeSDKService
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ async def monitor_disconnection(
17
+ request: Request, session_id: str, claude_service: "ClaudeSDKService"
18
+ ) -> None:
19
+ """Monitor for client disconnection and interrupt session if detected.
20
+
21
+ Args:
22
+ request: The incoming HTTP request
23
+ session_id: The Claude SDK session ID to interrupt if disconnected
24
+ claude_service: The Claude SDK service instance
25
+ """
26
+ try:
27
+ while True:
28
+ await asyncio.sleep(1.0) # Check every second
29
+ if await request.is_disconnected():
30
+ logger.info(
31
+ "client_disconnected_interrupting_session", session_id=session_id
32
+ )
33
+ try:
34
+ await claude_service.sdk_client.interrupt_session(session_id)
35
+ except Exception as e:
36
+ logger.error(
37
+ "failed_to_interrupt_session",
38
+ session_id=session_id,
39
+ error=str(e),
40
+ )
41
+ return
42
+ except asyncio.CancelledError:
43
+ # Task was cancelled, which is expected when streaming completes normally
44
+ logger.debug("disconnection_monitor_cancelled", session_id=session_id)
45
+ raise
46
+
47
+
48
+ async def monitor_stuck_stream(
49
+ session_id: str,
50
+ claude_service: "ClaudeSDKService",
51
+ first_chunk_event: asyncio.Event,
52
+ timeout: float = 10.0,
53
+ ) -> None:
54
+ """Monitor for stuck streams that don't produce a first chunk (SystemMessage).
55
+
56
+ Args:
57
+ session_id: The Claude SDK session ID to monitor
58
+ claude_service: The Claude SDK service instance
59
+ first_chunk_event: Event that will be set when first chunk is received
60
+ timeout: Seconds to wait for first chunk before considering stream stuck
61
+ """
62
+ try:
63
+ # Wait for first chunk with timeout
64
+ await asyncio.wait_for(first_chunk_event.wait(), timeout=timeout)
65
+ logger.debug("stuck_stream_first_chunk_received", session_id=session_id)
66
+ except TimeoutError:
67
+ logger.error(
68
+ "streaming_system_message_timeout",
69
+ session_id=session_id,
70
+ timeout=timeout,
71
+ message=f"No SystemMessage received within {timeout}s, interrupting session",
72
+ )
73
+ try:
74
+ await claude_service.sdk_client.interrupt_session(session_id)
75
+ logger.info("stuck_session_interrupted_successfully", session_id=session_id)
76
+ except Exception as e:
77
+ logger.error(
78
+ "failed_to_interrupt_stuck_session", session_id=session_id, error=str(e)
79
+ )
80
+ except asyncio.CancelledError:
81
+ # Task was cancelled, which is expected when streaming completes normally
82
+ logger.debug("stuck_stream_monitor_cancelled", session_id=session_id)
83
+ raise
@@ -0,0 +1,12 @@
1
+ """Utility functions for generating consistent IDs across the application."""
2
+
3
+ import uuid
4
+
5
+
6
+ def generate_client_id() -> str:
7
+ """Generate a consistent client ID for SDK connections.
8
+
9
+ Returns:
10
+ str: First part of a UUID4 (8 characters)
11
+ """
12
+ return str(uuid.uuid4()).split("-")[0]
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  # Combined mapping: OpenAI models → Claude models AND Claude aliases → canonical Claude models
11
11
  MODEL_MAPPING: dict[str, str] = {
12
+ "gpt-5": "claude-sonnet-4-20250514",
12
13
  # OpenAI GPT-4 models → Claude 3.5 Sonnet (most comparable)
13
14
  "gpt-4": "claude-3-5-sonnet-20241022",
14
15
  "gpt-4-turbo": "claude-3-5-sonnet-20241022",
@@ -80,11 +81,12 @@ def map_model_to_claude(model_name: str) -> str:
80
81
  return "claude-3-7-sonnet-20250219"
81
82
  elif model_name.startswith("gpt-3.5"):
82
83
  return "claude-3-5-haiku-latest"
83
- elif model_name.startswith("o1"):
84
- return "claude-sonnet-4-20250514"
85
- elif model_name.startswith("o3"):
86
- return "claude-opus-4-20250514"
87
- elif model_name.startswith("gpt"):
84
+ elif (
85
+ model_name.startswith("o1")
86
+ or model_name.startswith("gpt-5")
87
+ or model_name.startswith("o3")
88
+ or model_name.startswith("gpt")
89
+ ):
88
90
  return "claude-sonnet-4-20250514"
89
91
 
90
92
  # If it's already a Claude model, pass through unchanged
@@ -0,0 +1,470 @@
1
+ """Startup utility functions for application lifecycle management.
2
+
3
+ This module contains simple utility functions to extract and organize
4
+ the complex startup logic from the main lifespan function, following
5
+ the KISS principle and avoiding overengineering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime
11
+ from typing import TYPE_CHECKING
12
+
13
+ import structlog
14
+ from fastapi import FastAPI
15
+
16
+ from ccproxy.auth.credentials_adapter import CredentialsAuthManager
17
+ from ccproxy.auth.exceptions import CredentialsNotFoundError
18
+ from ccproxy.observability import get_metrics
19
+
20
+ # Note: get_claude_cli_info is imported locally to avoid circular imports
21
+ from ccproxy.observability.storage.duckdb_simple import SimpleDuckDBStorage
22
+ from ccproxy.scheduler.errors import SchedulerError
23
+ from ccproxy.scheduler.manager import start_scheduler, stop_scheduler
24
+ from ccproxy.services.claude_detection_service import ClaudeDetectionService
25
+ from ccproxy.services.claude_sdk_service import ClaudeSDKService
26
+ from ccproxy.services.codex_detection_service import CodexDetectionService
27
+ from ccproxy.services.credentials.manager import CredentialsManager
28
+
29
+
30
+ # Note: get_permission_service is imported locally to avoid circular imports
31
+
32
+ if TYPE_CHECKING:
33
+ from ccproxy.config.settings import Settings
34
+
35
+ logger = structlog.get_logger(__name__)
36
+
37
+
38
+ async def validate_authentication_startup(app: FastAPI, settings: Settings) -> None:
39
+ """Validate authentication credentials at startup.
40
+
41
+ Args:
42
+ app: FastAPI application instance
43
+ settings: Application settings
44
+ """
45
+ try:
46
+ credentials_manager = CredentialsManager()
47
+ validation = await credentials_manager.validate()
48
+
49
+ if validation.valid and not validation.expired:
50
+ credentials = validation.credentials
51
+ oauth_token = credentials.claude_ai_oauth if credentials else None
52
+
53
+ if oauth_token and oauth_token.expires_at_datetime:
54
+ hours_until_expiry = int(
55
+ (
56
+ oauth_token.expires_at_datetime - datetime.now(UTC)
57
+ ).total_seconds()
58
+ / 3600
59
+ )
60
+ logger.debug(
61
+ "auth_token_valid",
62
+ expires_in_hours=hours_until_expiry,
63
+ subscription_type=oauth_token.subscription_type,
64
+ credentials_path=str(validation.path) if validation.path else None,
65
+ )
66
+ else:
67
+ logger.debug("auth_token_valid", credentials_path=str(validation.path))
68
+ elif validation.expired:
69
+ logger.warning(
70
+ "auth_token_expired",
71
+ message="Authentication token has expired. Please run 'ccproxy auth login' to refresh.",
72
+ credentials_path=str(validation.path) if validation.path else None,
73
+ )
74
+ else:
75
+ logger.warning(
76
+ "auth_token_invalid",
77
+ message="Authentication token is invalid. Please run 'ccproxy auth login'.",
78
+ credentials_path=str(validation.path) if validation.path else None,
79
+ )
80
+ except CredentialsNotFoundError:
81
+ logger.warning(
82
+ "auth_token_not_found",
83
+ message="No authentication credentials found. Please run 'ccproxy auth login' to authenticate.",
84
+ searched_paths=settings.auth.storage.storage_paths,
85
+ )
86
+ except Exception as e:
87
+ logger.error(
88
+ "auth_token_validation_error",
89
+ error=str(e),
90
+ message="Failed to validate authentication token. The server will continue without authentication.",
91
+ exc_info=True,
92
+ )
93
+
94
+
95
+ async def check_claude_cli_startup(app: FastAPI, settings: Settings) -> None:
96
+ """Check Claude CLI availability at startup.
97
+
98
+ Args:
99
+ app: FastAPI application instance
100
+ settings: Application settings
101
+ """
102
+ try:
103
+ from ccproxy.api.routes.health import get_claude_cli_info
104
+
105
+ claude_info = await get_claude_cli_info()
106
+
107
+ if claude_info.status == "available":
108
+ logger.info(
109
+ "claude_cli_available",
110
+ status=claude_info.status,
111
+ version=claude_info.version,
112
+ binary_path=claude_info.binary_path,
113
+ )
114
+ else:
115
+ logger.warning(
116
+ "claude_cli_unavailable",
117
+ status=claude_info.status,
118
+ error=claude_info.error,
119
+ binary_path=claude_info.binary_path,
120
+ message=f"Claude CLI status: {claude_info.status}",
121
+ )
122
+ except Exception as e:
123
+ logger.error(
124
+ "claude_cli_check_failed",
125
+ error=str(e),
126
+ message="Failed to check Claude CLI status during startup",
127
+ )
128
+
129
+
130
+ async def check_codex_cli_startup(app: FastAPI, settings: Settings) -> None:
131
+ """Check Codex CLI availability at startup.
132
+
133
+ Args:
134
+ app: FastAPI application instance
135
+ settings: Application settings
136
+ """
137
+ try:
138
+ from ccproxy.api.routes.health import get_codex_cli_info
139
+
140
+ codex_info = await get_codex_cli_info()
141
+
142
+ if codex_info.status == "available":
143
+ logger.info(
144
+ "codex_cli_available",
145
+ status=codex_info.status,
146
+ version=codex_info.version,
147
+ binary_path=codex_info.binary_path,
148
+ )
149
+ else:
150
+ logger.warning(
151
+ "codex_cli_unavailable",
152
+ status=codex_info.status,
153
+ error=codex_info.error,
154
+ binary_path=codex_info.binary_path,
155
+ message=f"Codex CLI status: {codex_info.status}",
156
+ )
157
+ except Exception as e:
158
+ logger.error(
159
+ "codex_cli_check_failed",
160
+ error=str(e),
161
+ message="Failed to check Codex CLI status during startup",
162
+ )
163
+
164
+
165
+ async def initialize_log_storage_startup(app: FastAPI, settings: Settings) -> None:
166
+ """Initialize log storage if needed and backend is DuckDB.
167
+
168
+ Args:
169
+ app: FastAPI application instance
170
+ settings: Application settings
171
+ """
172
+ if (
173
+ settings.observability.needs_storage_backend
174
+ and settings.observability.log_storage_backend == "duckdb"
175
+ ):
176
+ try:
177
+ storage = SimpleDuckDBStorage(
178
+ database_path=settings.observability.duckdb_path
179
+ )
180
+ await storage.initialize()
181
+ app.state.log_storage = storage
182
+ logger.debug(
183
+ "log_storage_initialized",
184
+ backend="duckdb",
185
+ path=str(settings.observability.duckdb_path),
186
+ collection_enabled=settings.observability.logs_collection_enabled,
187
+ )
188
+ except Exception as e:
189
+ logger.error("log_storage_initialization_failed", error=str(e))
190
+ # Continue without log storage (graceful degradation)
191
+
192
+
193
+ async def initialize_log_storage_shutdown(app: FastAPI) -> None:
194
+ """Close log storage if initialized.
195
+
196
+ Args:
197
+ app: FastAPI application instance
198
+ """
199
+ if hasattr(app.state, "log_storage") and app.state.log_storage:
200
+ try:
201
+ await app.state.log_storage.close()
202
+ logger.debug("log_storage_closed")
203
+ except Exception as e:
204
+ logger.error("log_storage_close_failed", error=str(e))
205
+
206
+
207
+ async def setup_scheduler_startup(app: FastAPI, settings: Settings) -> None:
208
+ """Start scheduler system and configure tasks.
209
+
210
+ Args:
211
+ app: FastAPI application instance
212
+ settings: Application settings
213
+ """
214
+ try:
215
+ scheduler = await start_scheduler(settings)
216
+ app.state.scheduler = scheduler
217
+ logger.debug("scheduler_initialized")
218
+
219
+ # Add session pool stats task if session manager is available
220
+ if (
221
+ scheduler
222
+ and hasattr(app.state, "session_manager")
223
+ and app.state.session_manager
224
+ ):
225
+ try:
226
+ # Add session pool stats task that runs every minute
227
+ await scheduler.add_task(
228
+ task_name="session_pool_stats",
229
+ task_type="pool_stats",
230
+ interval_seconds=60, # Every minute
231
+ enabled=True,
232
+ pool_manager=app.state.session_manager,
233
+ )
234
+ logger.debug("session_pool_stats_task_added", interval_seconds=60)
235
+ except Exception as e:
236
+ logger.error(
237
+ "session_pool_stats_task_add_failed",
238
+ error=str(e),
239
+ error_type=type(e).__name__,
240
+ )
241
+ except SchedulerError as e:
242
+ logger.error("scheduler_initialization_failed", error=str(e))
243
+ # Continue startup even if scheduler fails (graceful degradation)
244
+
245
+
246
+ async def setup_scheduler_shutdown(app: FastAPI) -> None:
247
+ """Stop scheduler system.
248
+
249
+ Args:
250
+ app: FastAPI application instance
251
+ """
252
+ try:
253
+ scheduler = getattr(app.state, "scheduler", None)
254
+ await stop_scheduler(scheduler)
255
+ logger.debug("scheduler_stopped_lifespan")
256
+ except SchedulerError as e:
257
+ logger.error("scheduler_stop_failed", error=str(e))
258
+
259
+
260
+ async def setup_session_manager_shutdown(app: FastAPI) -> None:
261
+ """Shutdown Claude SDK session manager if it was created.
262
+
263
+ Args:
264
+ app: FastAPI application instance
265
+ """
266
+ if hasattr(app.state, "session_manager") and app.state.session_manager:
267
+ try:
268
+ await app.state.session_manager.shutdown()
269
+ logger.debug("claude_sdk_session_manager_shutdown")
270
+ except Exception as e:
271
+ logger.error("claude_sdk_session_manager_shutdown_failed", error=str(e))
272
+
273
+
274
+ async def initialize_claude_detection_startup(app: FastAPI, settings: Settings) -> None:
275
+ """Initialize Claude detection service.
276
+
277
+ Args:
278
+ app: FastAPI application instance
279
+ settings: Application settings
280
+ """
281
+ try:
282
+ logger.debug("initializing_claude_detection")
283
+ detection_service = ClaudeDetectionService(settings)
284
+ claude_data = await detection_service.initialize_detection()
285
+ app.state.claude_detection_data = claude_data
286
+ app.state.claude_detection_service = detection_service
287
+ logger.debug(
288
+ "claude_detection_completed",
289
+ version=claude_data.claude_version,
290
+ cached_at=claude_data.cached_at.isoformat(),
291
+ )
292
+ except Exception as e:
293
+ logger.error("claude_detection_startup_failed", error=str(e))
294
+ # Continue startup with fallback - detection service will provide fallback data
295
+ detection_service = ClaudeDetectionService(settings)
296
+ app.state.claude_detection_data = detection_service._get_fallback_data()
297
+ app.state.claude_detection_service = detection_service
298
+
299
+
300
+ async def initialize_codex_detection_startup(app: FastAPI, settings: Settings) -> None:
301
+ """Initialize Codex detection service.
302
+
303
+ Args:
304
+ app: FastAPI application instance
305
+ settings: Application settings
306
+ """
307
+ try:
308
+ logger.debug("initializing_codex_detection")
309
+ detection_service = CodexDetectionService(settings)
310
+ codex_data = await detection_service.initialize_detection()
311
+ app.state.codex_detection_data = codex_data
312
+ app.state.codex_detection_service = detection_service
313
+ logger.debug(
314
+ "codex_detection_completed",
315
+ version=codex_data.codex_version,
316
+ cached_at=codex_data.cached_at.isoformat(),
317
+ )
318
+ except Exception as e:
319
+ logger.error("codex_detection_startup_failed", error=str(e))
320
+ # Continue startup with fallback - detection service will provide fallback data
321
+ detection_service = CodexDetectionService(settings)
322
+ app.state.codex_detection_data = detection_service._get_fallback_data()
323
+ app.state.codex_detection_service = detection_service
324
+
325
+
326
+ async def initialize_claude_sdk_startup(app: FastAPI, settings: Settings) -> None:
327
+ """Initialize ClaudeSDKService and store in app state.
328
+
329
+ Args:
330
+ app: FastAPI application instance
331
+ settings: Application settings
332
+ """
333
+ try:
334
+ # Create auth manager with settings
335
+ auth_manager = CredentialsAuthManager()
336
+
337
+ # Get global metrics instance
338
+ metrics = get_metrics()
339
+
340
+ # Check if session pool should be enabled from settings configuration
341
+ use_session_pool = settings.claude.sdk_session_pool.enabled
342
+
343
+ # Initialize session manager if session pool is enabled
344
+ session_manager = None
345
+ if use_session_pool:
346
+ from ccproxy.claude_sdk.manager import SessionManager
347
+
348
+ # Create SessionManager with dependency injection
349
+ session_manager = SessionManager(
350
+ settings=settings, metrics_factory=lambda: metrics
351
+ )
352
+
353
+ # Start the session manager (initializes session pool if enabled)
354
+ await session_manager.start()
355
+
356
+ # Create ClaudeSDKService instance
357
+ claude_service = ClaudeSDKService(
358
+ auth_manager=auth_manager,
359
+ metrics=metrics,
360
+ settings=settings,
361
+ session_manager=session_manager,
362
+ )
363
+
364
+ # Store in app state for reuse in dependencies
365
+ app.state.claude_service = claude_service
366
+ app.state.session_manager = (
367
+ session_manager # Store session_manager for shutdown
368
+ )
369
+ logger.debug("claude_sdk_service_initialized")
370
+ except Exception as e:
371
+ logger.error("claude_sdk_service_initialization_failed", error=str(e))
372
+ # Continue startup even if ClaudeSDKService fails (graceful degradation)
373
+
374
+
375
+ async def initialize_permission_service_startup(
376
+ app: FastAPI, settings: Settings
377
+ ) -> None:
378
+ """Initialize permission service (conditional on builtin_permissions).
379
+
380
+ Args:
381
+ app: FastAPI application instance
382
+ settings: Application settings
383
+ """
384
+ if settings.claude.builtin_permissions:
385
+ try:
386
+ from ccproxy.api.services.permission_service import get_permission_service
387
+
388
+ permission_service = get_permission_service()
389
+
390
+ # Only connect terminal handler if not using external handler
391
+ if settings.server.use_terminal_permission_handler:
392
+ # terminal_handler = TerminalPermissionHandler()
393
+
394
+ # TODO: Terminal handler should subscribe to events from the service
395
+ # instead of trying to set a handler directly
396
+ # The service uses an event-based architecture, not direct handlers
397
+
398
+ # logger.info(
399
+ # "permission_handler_configured",
400
+ # handler_type="terminal",
401
+ # message="Connected terminal handler to permission service",
402
+ # )
403
+ # app.state.terminal_handler = terminal_handler
404
+ pass
405
+ else:
406
+ logger.debug(
407
+ "permission_handler_configured",
408
+ handler_type="external_sse",
409
+ message="Terminal permission handler disabled - use 'ccproxy permission-handler connect' to handle permissions",
410
+ )
411
+ logger.warning(
412
+ "permission_handler_required",
413
+ message="Start external handler with: ccproxy permission-handler connect",
414
+ )
415
+
416
+ # Start the permission service
417
+ await permission_service.start()
418
+
419
+ # Store references in app state
420
+ app.state.permission_service = permission_service
421
+
422
+ logger.debug(
423
+ "permission_service_initialized",
424
+ timeout_seconds=permission_service._timeout_seconds,
425
+ terminal_handler_enabled=settings.server.use_terminal_permission_handler,
426
+ builtin_permissions_enabled=True,
427
+ )
428
+ except Exception as e:
429
+ logger.error("permission_service_initialization_failed", error=str(e))
430
+ # Continue without permission service (API will work but without prompts)
431
+ else:
432
+ logger.debug(
433
+ "permission_service_skipped",
434
+ builtin_permissions_enabled=False,
435
+ message="Built-in permission handling disabled - users can configure custom MCP servers and permission tools",
436
+ )
437
+
438
+
439
+ async def setup_permission_service_shutdown(app: FastAPI, settings: Settings) -> None:
440
+ """Stop permission service (if it was initialized).
441
+
442
+ Args:
443
+ app: FastAPI application instance
444
+ settings: Application settings
445
+ """
446
+ if (
447
+ hasattr(app.state, "permission_service")
448
+ and app.state.permission_service
449
+ and settings.claude.builtin_permissions
450
+ ):
451
+ try:
452
+ await app.state.permission_service.stop()
453
+ logger.debug("permission_service_stopped")
454
+ except Exception as e:
455
+ logger.error("permission_service_stop_failed", error=str(e))
456
+
457
+
458
+ async def flush_streaming_batches_shutdown(app: FastAPI) -> None:
459
+ """Flush any remaining streaming log batches.
460
+
461
+ Args:
462
+ app: FastAPI application instance
463
+ """
464
+ try:
465
+ from ccproxy.utils.simple_request_logger import flush_all_streaming_batches
466
+
467
+ await flush_all_streaming_batches()
468
+ logger.debug("streaming_batches_flushed")
469
+ except Exception as e:
470
+ logger.error("streaming_batches_flush_failed", error=str(e))