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
@@ -205,6 +205,62 @@ class PrometheusMetrics:
205
205
  registry=self.registry,
206
206
  )
207
207
 
208
+ # Claude SDK Pool metrics
209
+ self.pool_clients_total = Gauge(
210
+ f"{self.namespace}_pool_clients_total",
211
+ "Total number of clients in the pool",
212
+ registry=self.registry,
213
+ )
214
+
215
+ self.pool_clients_available = Gauge(
216
+ f"{self.namespace}_pool_clients_available",
217
+ "Number of available clients in the pool",
218
+ registry=self.registry,
219
+ )
220
+
221
+ self.pool_clients_active = Gauge(
222
+ f"{self.namespace}_pool_clients_active",
223
+ "Number of active clients currently processing requests",
224
+ registry=self.registry,
225
+ )
226
+
227
+ self.pool_connections_created_total = Counter(
228
+ f"{self.namespace}_pool_connections_created_total",
229
+ "Total number of pool connections created",
230
+ registry=self.registry,
231
+ )
232
+
233
+ self.pool_connections_closed_total = Counter(
234
+ f"{self.namespace}_pool_connections_closed_total",
235
+ "Total number of pool connections closed",
236
+ registry=self.registry,
237
+ )
238
+
239
+ self.pool_acquisitions_total = Counter(
240
+ f"{self.namespace}_pool_acquisitions_total",
241
+ "Total number of client acquisitions from pool",
242
+ registry=self.registry,
243
+ )
244
+
245
+ self.pool_releases_total = Counter(
246
+ f"{self.namespace}_pool_releases_total",
247
+ "Total number of client releases to pool",
248
+ registry=self.registry,
249
+ )
250
+
251
+ self.pool_health_check_failures_total = Counter(
252
+ f"{self.namespace}_pool_health_check_failures_total",
253
+ "Total number of pool health check failures",
254
+ registry=self.registry,
255
+ )
256
+
257
+ self.pool_acquisition_duration = Histogram(
258
+ f"{self.namespace}_pool_acquisition_duration_seconds",
259
+ "Time taken to acquire a client from the pool",
260
+ buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
261
+ registry=self.registry,
262
+ )
263
+
208
264
  # Set initial system info
209
265
  try:
210
266
  from ccproxy import __version__
@@ -468,6 +524,101 @@ class PrometheusMetrics:
468
524
  and self._pushgateway_client.is_enabled()
469
525
  )
470
526
 
527
+ # Claude SDK Pool metrics methods
528
+
529
+ def update_pool_gauges(
530
+ self,
531
+ total_clients: int,
532
+ available_clients: int,
533
+ active_clients: int,
534
+ ) -> None:
535
+ """
536
+ Update pool gauge metrics (current state).
537
+
538
+ Args:
539
+ total_clients: Total number of clients in pool
540
+ available_clients: Number of available clients
541
+ active_clients: Number of active clients
542
+ """
543
+ if not self._enabled:
544
+ return
545
+
546
+ # Update gauges
547
+ self.pool_clients_total.set(total_clients)
548
+ self.pool_clients_available.set(available_clients)
549
+ self.pool_clients_active.set(active_clients)
550
+
551
+ # Note: Counters are managed directly by the pool operations
552
+ # This method only updates the current gauges
553
+
554
+ def record_pool_acquisition_time(self, duration_seconds: float) -> None:
555
+ """
556
+ Record the time taken to acquire a client from the pool.
557
+
558
+ Args:
559
+ duration_seconds: Time in seconds to acquire client
560
+ """
561
+ if not self._enabled:
562
+ return
563
+
564
+ self.pool_acquisition_duration.observe(duration_seconds)
565
+
566
+ def inc_pool_connections_created(self) -> None:
567
+ """Increment the pool connections created counter."""
568
+ if not self._enabled:
569
+ return
570
+
571
+ self.pool_connections_created_total.inc()
572
+
573
+ def inc_pool_connections_closed(self) -> None:
574
+ """Increment the pool connections closed counter."""
575
+ if not self._enabled:
576
+ return
577
+
578
+ self.pool_connections_closed_total.inc()
579
+
580
+ def inc_pool_acquisitions(self) -> None:
581
+ """Increment the pool acquisitions counter."""
582
+ if not self._enabled:
583
+ return
584
+
585
+ self.pool_acquisitions_total.inc()
586
+
587
+ def inc_pool_releases(self) -> None:
588
+ """Increment the pool releases counter."""
589
+ if not self._enabled:
590
+ return
591
+
592
+ self.pool_releases_total.inc()
593
+
594
+ def inc_pool_health_check_failures(self) -> None:
595
+ """Increment the pool health check failures counter."""
596
+ if not self._enabled:
597
+ return
598
+
599
+ self.pool_health_check_failures_total.inc()
600
+
601
+ def set_pool_clients_total(self, count: int) -> None:
602
+ """Set the total number of clients in the pool."""
603
+ if not self._enabled:
604
+ return
605
+
606
+ self.pool_clients_total.set(count)
607
+
608
+ def set_pool_clients_available(self, count: int) -> None:
609
+ """Set the number of available clients in the pool."""
610
+ if not self._enabled:
611
+ return
612
+
613
+ self.pool_clients_available.set(count)
614
+
615
+ def set_pool_clients_active(self, count: int) -> None:
616
+ """Set the number of active clients in the pool."""
617
+ if not self._enabled:
618
+ return
619
+
620
+ self.pool_clients_active.set(count)
621
+
471
622
 
472
623
  # Global metrics instance
473
624
  _global_metrics: PrometheusMetrics | None = None
@@ -60,6 +60,18 @@ class AccessLogPayload(TypedDict, total=False):
60
60
  cache_write_tokens: int
61
61
  cost_usd: float
62
62
  cost_sdk_usd: float
63
+ num_turns: int # number of conversation turns
64
+
65
+ # Session context metadata
66
+ session_type: str # "session_pool" or "direct"
67
+ session_status: str # active, idle, connecting, etc.
68
+ session_age_seconds: float # how long session has been alive
69
+ session_message_count: int # number of messages in session
70
+ session_client_id: str # unique session client identifier
71
+ session_pool_enabled: bool # whether session pooling is enabled
72
+ session_idle_seconds: float # how long since last activity
73
+ session_error_count: int # number of errors in this session
74
+ session_is_new: bool # whether this is a newly created session
63
75
 
64
76
 
65
77
  class SimpleDuckDBStorage:
@@ -44,6 +44,22 @@ class AccessLog(SQLModel, table=True):
44
44
  cache_write_tokens: int = Field(default=0)
45
45
  cost_usd: float = Field(default=0.0)
46
46
  cost_sdk_usd: float = Field(default=0.0)
47
+ num_turns: int = Field(default=0) # number of conversation turns
48
+
49
+ # Session context metadata
50
+ session_type: str = Field(default="") # "session_pool" or "direct"
51
+ session_status: str = Field(default="") # active, idle, connecting, etc.
52
+ session_age_seconds: float = Field(default=0.0) # how long session has been alive
53
+ session_message_count: int = Field(default=0) # number of messages in session
54
+ session_client_id: str = Field(default="") # unique session client identifier
55
+ session_pool_enabled: bool = Field(
56
+ default=False
57
+ ) # whether session pooling is enabled
58
+ session_idle_seconds: float = Field(default=0.0) # how long since last activity
59
+ session_error_count: int = Field(default=0) # number of errors in this session
60
+ session_is_new: bool = Field(
61
+ default=True
62
+ ) # whether this is a newly created session
47
63
 
48
64
  class Config:
49
65
  """SQLModel configuration."""
@@ -0,0 +1,107 @@
1
+ """FastAPI StreamingResponse with automatic access logging on completion.
2
+
3
+ This module provides a reusable StreamingResponseWithLogging class that wraps
4
+ any async generator and handles access logging when the stream completes,
5
+ eliminating code duplication between different streaming endpoints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncGenerator, AsyncIterator
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import structlog
14
+ from fastapi.responses import StreamingResponse
15
+
16
+ from ccproxy.observability.access_logger import log_request_access
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from ccproxy.observability.context import RequestContext
21
+ from ccproxy.observability.metrics import PrometheusMetrics
22
+
23
+ logger = structlog.get_logger(__name__)
24
+
25
+
26
+ class StreamingResponseWithLogging(StreamingResponse):
27
+ """FastAPI StreamingResponse that triggers access logging on completion.
28
+
29
+ This class wraps a streaming response generator to automatically trigger
30
+ access logging when the stream completes (either successfully or with an error).
31
+ This eliminates the need for manual access logging in individual stream processors.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ content: AsyncGenerator[bytes, None] | AsyncIterator[bytes],
37
+ request_context: RequestContext,
38
+ metrics: PrometheusMetrics | None = None,
39
+ status_code: int = 200,
40
+ **kwargs: Any,
41
+ ) -> None:
42
+ """Initialize streaming response with logging capability.
43
+
44
+ Args:
45
+ content: The async generator producing streaming content
46
+ request_context: The request context for access logging
47
+ metrics: Optional PrometheusMetrics instance for recording metrics
48
+ status_code: HTTP status code for the response
49
+ **kwargs: Additional arguments passed to StreamingResponse
50
+ """
51
+ # Wrap the content generator to add logging
52
+ logged_content = self._wrap_with_logging(
53
+ content, request_context, metrics, status_code
54
+ )
55
+ super().__init__(logged_content, status_code=status_code, **kwargs)
56
+
57
+ async def _wrap_with_logging(
58
+ self,
59
+ content: AsyncGenerator[bytes, None] | AsyncIterator[bytes],
60
+ context: RequestContext,
61
+ metrics: PrometheusMetrics | None,
62
+ status_code: int,
63
+ ) -> AsyncGenerator[bytes, None]:
64
+ """Wrap content generator with access logging on completion.
65
+
66
+ Args:
67
+ content: The original content generator
68
+ context: Request context for logging
69
+ metrics: Optional metrics instance
70
+ status_code: HTTP status code
71
+
72
+ Yields:
73
+ bytes: Content chunks from the original generator
74
+ """
75
+ try:
76
+ # Stream all content from the original generator
77
+ async for chunk in content:
78
+ yield chunk
79
+ except GeneratorExit:
80
+ # Client disconnected - log this and re-raise to propagate to underlying generators
81
+ logger.info(
82
+ "streaming_response_client_disconnected",
83
+ request_id=context.request_id,
84
+ message="Client disconnected from streaming response, propagating GeneratorExit",
85
+ )
86
+ # CRITICAL: Re-raise GeneratorExit to propagate disconnect to create_listener()
87
+ raise
88
+ finally:
89
+ # Log access when stream completes (success or error)
90
+ try:
91
+ # Add streaming completion event type to context
92
+ context.add_metadata(event_type="streaming_complete")
93
+
94
+ # Check if status_code was updated in context metadata (e.g., due to error)
95
+ final_status_code = context.metadata.get("status_code", status_code)
96
+
97
+ await log_request_access(
98
+ context=context,
99
+ status_code=final_status_code,
100
+ metrics=metrics,
101
+ )
102
+ except Exception as e:
103
+ logger.warning(
104
+ "streaming_access_log_failed",
105
+ error=str(e),
106
+ request_id=context.request_id,
107
+ )
@@ -7,6 +7,7 @@ from ccproxy.config.settings import Settings
7
7
  from .core import Scheduler
8
8
  from .registry import register_task
9
9
  from .tasks import (
10
+ PoolStatsTask,
10
11
  PricingCacheUpdateTask,
11
12
  PushgatewayTask,
12
13
  StatsPrintingTask,
@@ -31,6 +32,19 @@ async def setup_scheduler_tasks(scheduler: Scheduler, settings: Settings) -> Non
31
32
  logger.info("scheduler_disabled")
32
33
  return
33
34
 
35
+ # Log network features status
36
+ logger.info(
37
+ "network_features_status",
38
+ pricing_updates_enabled=scheduler_config.pricing_update_enabled,
39
+ version_check_enabled=scheduler_config.version_check_enabled,
40
+ message=(
41
+ "Network features disabled by default for privacy"
42
+ if not scheduler_config.pricing_update_enabled
43
+ and not scheduler_config.version_check_enabled
44
+ else "Some network features are enabled"
45
+ ),
46
+ )
47
+
34
48
  # Add pushgateway task if enabled
35
49
  if scheduler_config.pushgateway_enabled:
36
50
  try:
@@ -123,21 +137,32 @@ async def setup_scheduler_tasks(scheduler: Scheduler, settings: Settings) -> Non
123
137
  )
124
138
 
125
139
 
126
- def _register_default_tasks() -> None:
127
- """Register default task types in the global registry."""
140
+ def _register_default_tasks(settings: Settings) -> None:
141
+ """Register default task types in the global registry based on configuration."""
128
142
  from .registry import get_task_registry
129
143
 
130
144
  registry = get_task_registry()
145
+ scheduler_config = settings.scheduler
131
146
 
132
- # Only register if not already registered
133
- if not registry.is_registered("pushgateway"):
147
+ # Only register pushgateway task if enabled
148
+ if scheduler_config.pushgateway_enabled and not registry.is_registered(
149
+ "pushgateway"
150
+ ):
134
151
  register_task("pushgateway", PushgatewayTask)
135
- if not registry.is_registered("stats_printing"):
152
+
153
+ # Only register stats printing task if enabled
154
+ if scheduler_config.stats_printing_enabled and not registry.is_registered(
155
+ "stats_printing"
156
+ ):
136
157
  register_task("stats_printing", StatsPrintingTask)
158
+
159
+ # Always register core tasks (not metrics-related)
137
160
  if not registry.is_registered("pricing_cache_update"):
138
161
  register_task("pricing_cache_update", PricingCacheUpdateTask)
139
162
  if not registry.is_registered("version_update_check"):
140
163
  register_task("version_update_check", VersionUpdateCheckTask)
164
+ if not registry.is_registered("pool_stats"):
165
+ register_task("pool_stats", PoolStatsTask)
141
166
 
142
167
 
143
168
  async def start_scheduler(settings: Settings) -> Scheduler | None:
@@ -156,7 +181,7 @@ async def start_scheduler(settings: Settings) -> Scheduler | None:
156
181
  return None
157
182
 
158
183
  # Register task types (only when actually starting scheduler)
159
- _register_default_tasks()
184
+ _register_default_tasks(settings)
160
185
 
161
186
  # Create scheduler with settings
162
187
  scheduler = Scheduler(
@@ -483,6 +483,128 @@ class PricingCacheUpdateTask(BaseScheduledTask):
483
483
  return False
484
484
 
485
485
 
486
+ class PoolStatsTask(BaseScheduledTask):
487
+ """Task for displaying pool statistics periodically."""
488
+
489
+ def __init__(
490
+ self,
491
+ name: str,
492
+ interval_seconds: float,
493
+ enabled: bool = True,
494
+ pool_manager: Any | None = None,
495
+ ):
496
+ """
497
+ Initialize pool stats task.
498
+
499
+ Args:
500
+ name: Task name
501
+ interval_seconds: Interval between stats display
502
+ enabled: Whether task is enabled
503
+ pool_manager: Injected pool manager instance
504
+ """
505
+ super().__init__(
506
+ name=name,
507
+ interval_seconds=interval_seconds,
508
+ enabled=enabled,
509
+ )
510
+ self._pool_manager = pool_manager
511
+
512
+ async def setup(self) -> None:
513
+ """Initialize pool manager instance if not injected."""
514
+ if self._pool_manager is None:
515
+ logger.warning(
516
+ "pool_stats_task_no_manager",
517
+ task_name=self.name,
518
+ message="Pool manager not injected, task will be disabled",
519
+ )
520
+
521
+ async def run(self) -> bool:
522
+ """Display pool statistics."""
523
+ try:
524
+ if not self._pool_manager:
525
+ return True # Not an error, just no pool manager available
526
+
527
+ # Get general pool stats (if available)
528
+ general_pool = getattr(self._pool_manager, "_pool", None)
529
+ general_stats = None
530
+ if general_pool:
531
+ general_stats = general_pool.get_stats()
532
+
533
+ # Get session pool stats
534
+ session_pool = getattr(self._pool_manager, "_session_pool", None)
535
+ session_stats = None
536
+ if session_pool:
537
+ session_stats = await session_pool.get_stats()
538
+
539
+ # Log pool statistics
540
+ logger.debug(
541
+ "pool_stats_report",
542
+ task_name=self.name,
543
+ general_pool={
544
+ "enabled": bool(general_pool),
545
+ "total_clients": general_stats.total_clients
546
+ if general_stats
547
+ else 0,
548
+ "available_clients": general_stats.available_clients
549
+ if general_stats
550
+ else 0,
551
+ "active_clients": general_stats.active_clients
552
+ if general_stats
553
+ else 0,
554
+ "connections_created": general_stats.connections_created
555
+ if general_stats
556
+ else 0,
557
+ "connections_closed": general_stats.connections_closed
558
+ if general_stats
559
+ else 0,
560
+ "acquire_count": general_stats.acquire_count
561
+ if general_stats
562
+ else 0,
563
+ "release_count": general_stats.release_count
564
+ if general_stats
565
+ else 0,
566
+ "health_check_failures": general_stats.health_check_failures
567
+ if general_stats
568
+ else 0,
569
+ }
570
+ if general_pool
571
+ else None,
572
+ session_pool={
573
+ "enabled": session_stats.get("enabled", False)
574
+ if session_stats
575
+ else False,
576
+ "total_sessions": session_stats.get("total_sessions", 0)
577
+ if session_stats
578
+ else 0,
579
+ "active_sessions": session_stats.get("active_sessions", 0)
580
+ if session_stats
581
+ else 0,
582
+ "max_sessions": session_stats.get("max_sessions", 0)
583
+ if session_stats
584
+ else 0,
585
+ "total_messages": session_stats.get("total_messages", 0)
586
+ if session_stats
587
+ else 0,
588
+ "session_ttl": session_stats.get("session_ttl", 0)
589
+ if session_stats
590
+ else 0,
591
+ }
592
+ if session_pool
593
+ else None,
594
+ )
595
+
596
+ return True
597
+
598
+ except Exception as e:
599
+ logger.error(
600
+ "pool_stats_task_error",
601
+ task_name=self.name,
602
+ error=str(e),
603
+ error_type=type(e).__name__,
604
+ )
605
+ return False
606
+
607
+
486
608
  class VersionUpdateCheckTask(BaseScheduledTask):
487
609
  """Task for checking version updates periodically."""
488
610