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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/codex/__init__.py +11 -0
- ccproxy/adapters/openai/adapter.py +1 -1
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/adapters/openai/streaming.py +1 -0
- ccproxy/api/app.py +150 -224
- ccproxy/api/dependencies.py +22 -2
- ccproxy/api/middleware/errors.py +27 -3
- ccproxy/api/middleware/logging.py +4 -0
- ccproxy/api/responses.py +6 -1
- ccproxy/api/routes/claude.py +222 -17
- ccproxy/api/routes/codex.py +1231 -0
- ccproxy/api/routes/health.py +228 -3
- ccproxy/api/routes/proxy.py +25 -6
- ccproxy/api/services/permission_service.py +2 -2
- ccproxy/auth/openai/__init__.py +13 -0
- ccproxy/auth/openai/credentials.py +166 -0
- ccproxy/auth/openai/oauth_client.py +334 -0
- ccproxy/auth/openai/storage.py +184 -0
- ccproxy/claude_sdk/__init__.py +4 -8
- ccproxy/claude_sdk/client.py +661 -131
- ccproxy/claude_sdk/exceptions.py +16 -0
- ccproxy/claude_sdk/manager.py +219 -0
- ccproxy/claude_sdk/message_queue.py +342 -0
- ccproxy/claude_sdk/options.py +6 -1
- ccproxy/claude_sdk/session_client.py +546 -0
- ccproxy/claude_sdk/session_pool.py +550 -0
- ccproxy/claude_sdk/stream_handle.py +538 -0
- ccproxy/claude_sdk/stream_worker.py +392 -0
- ccproxy/claude_sdk/streaming.py +53 -11
- ccproxy/cli/commands/auth.py +398 -1
- ccproxy/cli/commands/serve.py +99 -1
- ccproxy/cli/options/claude_options.py +47 -0
- ccproxy/config/__init__.py +0 -3
- ccproxy/config/claude.py +171 -23
- ccproxy/config/codex.py +100 -0
- ccproxy/config/discovery.py +10 -1
- ccproxy/config/scheduler.py +2 -2
- ccproxy/config/settings.py +38 -1
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +458 -75
- ccproxy/core/logging.py +108 -12
- ccproxy/core/transformers.py +5 -0
- ccproxy/models/claude_sdk.py +57 -0
- ccproxy/models/detection.py +208 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -0
- ccproxy/observability/access_logger.py +72 -14
- ccproxy/observability/metrics.py +151 -0
- ccproxy/observability/storage/duckdb_simple.py +12 -0
- ccproxy/observability/storage/models.py +16 -0
- ccproxy/observability/streaming_response.py +107 -0
- ccproxy/scheduler/manager.py +31 -6
- ccproxy/scheduler/tasks.py +122 -0
- ccproxy/services/claude_detection_service.py +269 -0
- ccproxy/services/claude_sdk_service.py +333 -130
- ccproxy/services/codex_detection_service.py +263 -0
- ccproxy/services/proxy_service.py +618 -197
- ccproxy/utils/__init__.py +9 -1
- ccproxy/utils/disconnection_monitor.py +83 -0
- ccproxy/utils/id_generator.py +12 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +470 -0
- ccproxy_api-0.1.6.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
- ccproxy/config/loader.py +0 -105
- ccproxy_api-0.1.4.dist-info/METADATA +0 -369
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
- {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
|
+
)
|