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
@@ -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
+ )