ccproxy-api 0.1.3__py3-none-any.whl → 0.1.5__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 (54) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/adapter.py +1 -1
  3. ccproxy/adapters/openai/streaming.py +1 -0
  4. ccproxy/api/app.py +134 -224
  5. ccproxy/api/dependencies.py +22 -2
  6. ccproxy/api/middleware/errors.py +27 -3
  7. ccproxy/api/middleware/logging.py +4 -0
  8. ccproxy/api/responses.py +6 -1
  9. ccproxy/api/routes/claude.py +222 -17
  10. ccproxy/api/routes/proxy.py +25 -6
  11. ccproxy/api/services/permission_service.py +2 -2
  12. ccproxy/claude_sdk/__init__.py +4 -8
  13. ccproxy/claude_sdk/client.py +661 -131
  14. ccproxy/claude_sdk/exceptions.py +16 -0
  15. ccproxy/claude_sdk/manager.py +219 -0
  16. ccproxy/claude_sdk/message_queue.py +342 -0
  17. ccproxy/claude_sdk/options.py +5 -0
  18. ccproxy/claude_sdk/session_client.py +546 -0
  19. ccproxy/claude_sdk/session_pool.py +550 -0
  20. ccproxy/claude_sdk/stream_handle.py +538 -0
  21. ccproxy/claude_sdk/stream_worker.py +392 -0
  22. ccproxy/claude_sdk/streaming.py +53 -11
  23. ccproxy/cli/commands/serve.py +96 -0
  24. ccproxy/cli/options/claude_options.py +47 -0
  25. ccproxy/config/__init__.py +0 -3
  26. ccproxy/config/claude.py +171 -23
  27. ccproxy/config/discovery.py +10 -1
  28. ccproxy/config/scheduler.py +4 -4
  29. ccproxy/config/settings.py +19 -1
  30. ccproxy/core/http_transformers.py +305 -73
  31. ccproxy/core/logging.py +108 -12
  32. ccproxy/core/transformers.py +5 -0
  33. ccproxy/models/claude_sdk.py +57 -0
  34. ccproxy/models/detection.py +126 -0
  35. ccproxy/observability/access_logger.py +72 -14
  36. ccproxy/observability/metrics.py +151 -0
  37. ccproxy/observability/storage/duckdb_simple.py +12 -0
  38. ccproxy/observability/storage/models.py +16 -0
  39. ccproxy/observability/streaming_response.py +107 -0
  40. ccproxy/scheduler/manager.py +31 -6
  41. ccproxy/scheduler/tasks.py +122 -0
  42. ccproxy/services/claude_detection_service.py +269 -0
  43. ccproxy/services/claude_sdk_service.py +334 -131
  44. ccproxy/services/proxy_service.py +91 -200
  45. ccproxy/utils/__init__.py +9 -1
  46. ccproxy/utils/disconnection_monitor.py +83 -0
  47. ccproxy/utils/id_generator.py +12 -0
  48. ccproxy/utils/startup_helpers.py +408 -0
  49. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
  50. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
  51. ccproxy/config/loader.py +0 -105
  52. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
  53. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
  54. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -0,0 +1,269 @@
1
+ """Service for automatically detecting Claude CLI headers at startup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import socket
9
+ import subprocess
10
+ from typing import Any
11
+
12
+ import structlog
13
+ from fastapi import FastAPI, Request, Response
14
+
15
+ from ccproxy.config.discovery import get_ccproxy_cache_dir
16
+ from ccproxy.config.settings import Settings
17
+ from ccproxy.models.detection import (
18
+ ClaudeCacheData,
19
+ ClaudeCodeHeaders,
20
+ SystemPromptData,
21
+ )
22
+
23
+
24
+ logger = structlog.get_logger(__name__)
25
+
26
+
27
+ class ClaudeDetectionService:
28
+ """Service for automatically detecting Claude CLI headers at startup."""
29
+
30
+ def __init__(self, settings: Settings) -> None:
31
+ """Initialize Claude detection service."""
32
+ self.settings = settings
33
+ self.cache_dir = get_ccproxy_cache_dir()
34
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
35
+ self._cached_data: ClaudeCacheData | None = None
36
+
37
+ async def initialize_detection(self) -> ClaudeCacheData:
38
+ """Initialize Claude detection at startup."""
39
+ try:
40
+ # Get current Claude version
41
+ current_version = await self._get_claude_version()
42
+
43
+ # Try to load from cache first
44
+ detected_data = self._load_from_cache(current_version)
45
+ cached = detected_data is not None
46
+ if cached:
47
+ logger.debug("detection_claude_headers_debug", version=current_version)
48
+ else:
49
+ # No cache or version changed - detect fresh
50
+ detected_data = await self._detect_claude_headers(current_version)
51
+ # Cache the results
52
+ self._save_to_cache(detected_data)
53
+
54
+ self._cached_data = detected_data
55
+
56
+ logger.info(
57
+ "detection_claude_headers_completed",
58
+ version=current_version,
59
+ cached=cached,
60
+ )
61
+
62
+ # TODO: add proper testing without claude cli installed
63
+ if detected_data is None:
64
+ raise ValueError("Claude detection failed")
65
+ return detected_data
66
+
67
+ except Exception as e:
68
+ logger.warning("detection_claude_headers_failed", fallback=True, error=e)
69
+ # Return fallback data
70
+ fallback_data = self._get_fallback_data()
71
+ self._cached_data = fallback_data
72
+ return fallback_data
73
+
74
+ def get_cached_data(self) -> ClaudeCacheData | None:
75
+ """Get currently cached detection data."""
76
+ return self._cached_data
77
+
78
+ async def _get_claude_version(self) -> str:
79
+ """Get Claude CLI version."""
80
+ try:
81
+ result = subprocess.run(
82
+ ["claude", "--version"],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=10,
86
+ )
87
+ if result.returncode == 0:
88
+ # Extract version from output like "1.0.60 (Claude Code)"
89
+ version_line = result.stdout.strip()
90
+ if "/" in version_line:
91
+ # Handle "claude-cli/1.0.60" format
92
+ version_line = version_line.split("/")[-1]
93
+ if "(" in version_line:
94
+ # Handle "1.0.60 (Claude Code)" format - extract just the version number
95
+ return version_line.split("(")[0].strip()
96
+ return version_line
97
+ else:
98
+ raise RuntimeError(f"Claude version command failed: {result.stderr}")
99
+
100
+ except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
101
+ logger.warning("claude_version_detection_failed", error=str(e))
102
+ return "unknown"
103
+
104
+ async def _detect_claude_headers(self, version: str) -> ClaudeCacheData:
105
+ """Execute Claude CLI with proxy to capture headers and system prompt."""
106
+ # Data captured from the request
107
+ captured_data: dict[str, Any] = {}
108
+
109
+ async def capture_handler(request: Request) -> Response:
110
+ """Capture the Claude CLI request."""
111
+ captured_data["headers"] = dict(request.headers)
112
+ captured_data["body"] = await request.body()
113
+ # Return a mock response to satisfy Claude CLI
114
+ return Response(
115
+ content='{"type": "message", "content": [{"type": "text", "text": "Test response"}]}',
116
+ media_type="application/json",
117
+ status_code=200,
118
+ )
119
+
120
+ # Create temporary FastAPI app
121
+ temp_app = FastAPI()
122
+ temp_app.post("/v1/messages")(capture_handler)
123
+
124
+ # Find available port
125
+ sock = socket.socket()
126
+ sock.bind(("", 0))
127
+ port = sock.getsockname()[1]
128
+ sock.close()
129
+
130
+ # Start server in background
131
+ from uvicorn import Config, Server
132
+
133
+ config = Config(temp_app, host="127.0.0.1", port=port, log_level="error")
134
+ server = Server(config)
135
+
136
+ server_task = asyncio.create_task(server.serve())
137
+
138
+ try:
139
+ # Wait for server to start
140
+ await asyncio.sleep(0.5)
141
+
142
+ # Execute Claude CLI with proxy
143
+ env = {**dict(os.environ), "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}"}
144
+
145
+ process = await asyncio.create_subprocess_exec(
146
+ "claude",
147
+ "test",
148
+ env=env,
149
+ stdout=asyncio.subprocess.PIPE,
150
+ stderr=asyncio.subprocess.PIPE,
151
+ )
152
+
153
+ # Wait for process with timeout
154
+ try:
155
+ await asyncio.wait_for(process.wait(), timeout=30)
156
+ except TimeoutError:
157
+ process.kill()
158
+ await process.wait()
159
+
160
+ # Stop server
161
+ server.should_exit = True
162
+ await server_task
163
+
164
+ if not captured_data:
165
+ raise RuntimeError("Failed to capture Claude CLI request")
166
+
167
+ # Extract headers and system prompt
168
+ headers = self._extract_headers(captured_data["headers"])
169
+ system_prompt = self._extract_system_prompt(captured_data["body"])
170
+
171
+ return ClaudeCacheData(
172
+ claude_version=version, headers=headers, system_prompt=system_prompt
173
+ )
174
+
175
+ except Exception as e:
176
+ # Ensure server is stopped
177
+ server.should_exit = True
178
+ if not server_task.done():
179
+ await server_task
180
+ raise
181
+
182
+ def _load_from_cache(self, version: str) -> ClaudeCacheData | None:
183
+ """Load cached data for specific Claude version."""
184
+ cache_file = self.cache_dir / f"claude_headers_{version}.json"
185
+
186
+ if not cache_file.exists():
187
+ return None
188
+
189
+ try:
190
+ with cache_file.open("r") as f:
191
+ data = json.load(f)
192
+ return ClaudeCacheData.model_validate(data)
193
+ except Exception:
194
+ return None
195
+
196
+ def _save_to_cache(self, data: ClaudeCacheData) -> None:
197
+ """Save detection data to cache."""
198
+ cache_file = self.cache_dir / f"claude_headers_{data.claude_version}.json"
199
+
200
+ try:
201
+ with cache_file.open("w") as f:
202
+ json.dump(data.model_dump(), f, indent=2, default=str)
203
+ logger.debug(
204
+ "cache_saved", file=str(cache_file), version=data.claude_version
205
+ )
206
+ except Exception as e:
207
+ logger.warning("cache_save_failed", file=str(cache_file), error=str(e))
208
+
209
+ def _extract_headers(self, headers: dict[str, str]) -> ClaudeCodeHeaders:
210
+ """Extract Claude CLI headers from captured request."""
211
+ try:
212
+ return ClaudeCodeHeaders.model_validate(headers)
213
+ except Exception as e:
214
+ logger.error("header_extraction_failed", error=str(e))
215
+ raise ValueError(f"Failed to extract required headers: {e}") from e
216
+
217
+ def _extract_system_prompt(self, body: bytes) -> SystemPromptData:
218
+ """Extract system prompt from captured request body."""
219
+ try:
220
+ data = json.loads(body.decode("utf-8"))
221
+ system_content = data.get("system")
222
+
223
+ if system_content is None:
224
+ raise ValueError("No system field found in request body")
225
+
226
+ return SystemPromptData(system_field=system_content)
227
+
228
+ except Exception as e:
229
+ logger.error("system_prompt_extraction_failed", error=str(e))
230
+ raise ValueError(f"Failed to extract system prompt: {e}") from e
231
+
232
+ def _get_fallback_data(self) -> ClaudeCacheData:
233
+ """Get fallback data when detection fails."""
234
+ logger.warning("using_fallback_claude_data")
235
+
236
+ # Use existing hardcoded values as fallback
237
+ fallback_headers = ClaudeCodeHeaders(
238
+ **{
239
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
240
+ "anthropic-version": "2023-06-01",
241
+ "anthropic-dangerous-direct-browser-access": "true",
242
+ "x-app": "cli",
243
+ "User-Agent": "claude-cli/1.0.60 (external, cli)",
244
+ "X-Stainless-Lang": "js",
245
+ "X-Stainless-Retry-Count": "0",
246
+ "X-Stainless-Timeout": "60",
247
+ "X-Stainless-Package-Version": "0.55.1",
248
+ "X-Stainless-OS": "Linux",
249
+ "X-Stainless-Arch": "x64",
250
+ "X-Stainless-Runtime": "node",
251
+ "X-Stainless-Runtime-Version": "v24.3.0",
252
+ }
253
+ )
254
+
255
+ fallback_prompt = SystemPromptData(
256
+ system_field=[
257
+ {
258
+ "type": "text",
259
+ "text": "You are Claude Code, Anthropic's official CLI for Claude.",
260
+ "cache_control": {"type": "ephemeral"},
261
+ }
262
+ ]
263
+ )
264
+
265
+ return ClaudeCacheData(
266
+ claude_version="fallback",
267
+ headers=fallback_headers,
268
+ system_prompt=fallback_prompt,
269
+ )