ccproxy-api 0.1.5__py3-none-any.whl → 0.1.7__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 (42) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/codex/__init__.py +11 -0
  3. ccproxy/adapters/openai/models.py +1 -1
  4. ccproxy/adapters/openai/response_adapter.py +355 -0
  5. ccproxy/adapters/openai/response_models.py +178 -0
  6. ccproxy/api/app.py +31 -3
  7. ccproxy/api/dependencies.py +1 -8
  8. ccproxy/api/middleware/errors.py +15 -7
  9. ccproxy/api/routes/codex.py +1251 -0
  10. ccproxy/api/routes/health.py +228 -3
  11. ccproxy/auth/openai/__init__.py +13 -0
  12. ccproxy/auth/openai/credentials.py +166 -0
  13. ccproxy/auth/openai/oauth_client.py +334 -0
  14. ccproxy/auth/openai/storage.py +184 -0
  15. ccproxy/claude_sdk/options.py +1 -1
  16. ccproxy/cli/commands/auth.py +398 -1
  17. ccproxy/cli/commands/serve.py +3 -1
  18. ccproxy/config/claude.py +1 -1
  19. ccproxy/config/codex.py +100 -0
  20. ccproxy/config/scheduler.py +8 -8
  21. ccproxy/config/settings.py +19 -0
  22. ccproxy/core/codex_transformers.py +389 -0
  23. ccproxy/core/http_transformers.py +153 -2
  24. ccproxy/data/claude_headers_fallback.json +37 -0
  25. ccproxy/data/codex_headers_fallback.json +14 -0
  26. ccproxy/models/detection.py +82 -0
  27. ccproxy/models/requests.py +22 -0
  28. ccproxy/models/responses.py +16 -0
  29. ccproxy/scheduler/manager.py +2 -2
  30. ccproxy/scheduler/tasks.py +105 -65
  31. ccproxy/services/claude_detection_service.py +7 -33
  32. ccproxy/services/codex_detection_service.py +252 -0
  33. ccproxy/services/proxy_service.py +530 -0
  34. ccproxy/utils/model_mapping.py +7 -5
  35. ccproxy/utils/startup_helpers.py +205 -12
  36. ccproxy/utils/version_checker.py +6 -0
  37. ccproxy_api-0.1.7.dist-info/METADATA +615 -0
  38. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/RECORD +41 -28
  39. ccproxy_api-0.1.5.dist-info/METADATA +0 -396
  40. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/WHEEL +0 -0
  41. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/entry_points.txt +0 -0
  42. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,252 @@
1
+ """Service for automatically detecting Codex 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 pathlib import Path
11
+ from typing import Any
12
+
13
+ import structlog
14
+ from fastapi import FastAPI, Request, Response
15
+
16
+ from ccproxy.config.discovery import get_ccproxy_cache_dir
17
+ from ccproxy.config.settings import Settings
18
+ from ccproxy.models.detection import (
19
+ CodexCacheData,
20
+ CodexHeaders,
21
+ CodexInstructionsData,
22
+ )
23
+
24
+
25
+ logger = structlog.get_logger(__name__)
26
+
27
+
28
+ class CodexDetectionService:
29
+ """Service for automatically detecting Codex CLI headers at startup."""
30
+
31
+ def __init__(self, settings: Settings) -> None:
32
+ """Initialize Codex detection service."""
33
+ self.settings = settings
34
+ self.cache_dir = get_ccproxy_cache_dir()
35
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
36
+ self._cached_data: CodexCacheData | None = None
37
+
38
+ async def initialize_detection(self) -> CodexCacheData:
39
+ """Initialize Codex detection at startup."""
40
+ try:
41
+ # Get current Codex version
42
+ current_version = await self._get_codex_version()
43
+
44
+ # Try to load from cache first
45
+ detected_data = self._load_from_cache(current_version)
46
+ cached = detected_data is not None
47
+ if cached:
48
+ logger.debug("detection_codex_headers_debug", version=current_version)
49
+ else:
50
+ # No cache or version changed - detect fresh
51
+ detected_data = await self._detect_codex_headers(current_version)
52
+ # Cache the results
53
+ self._save_to_cache(detected_data)
54
+
55
+ self._cached_data = detected_data
56
+
57
+ logger.info(
58
+ "detection_codex_headers_completed",
59
+ version=current_version,
60
+ cached=cached,
61
+ )
62
+
63
+ # TODO: add proper testing without codex cli installed
64
+ if detected_data is None:
65
+ raise ValueError("Codex detection failed")
66
+ return detected_data
67
+
68
+ except Exception as e:
69
+ logger.warning("detection_codex_headers_failed", fallback=True, error=e)
70
+ # Return fallback data
71
+ fallback_data = self._get_fallback_data()
72
+ self._cached_data = fallback_data
73
+ return fallback_data
74
+
75
+ def get_cached_data(self) -> CodexCacheData | None:
76
+ """Get currently cached detection data."""
77
+ return self._cached_data
78
+
79
+ async def _get_codex_version(self) -> str:
80
+ """Get Codex CLI version."""
81
+ try:
82
+ result = subprocess.run(
83
+ ["codex", "--version"],
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=10,
87
+ )
88
+ if result.returncode == 0:
89
+ # Extract version from output like "codex 0.21.0"
90
+ version_line = result.stdout.strip()
91
+ if " " in version_line:
92
+ # Handle "codex 0.21.0" format - extract just the version number
93
+ return version_line.split()[-1]
94
+ return version_line
95
+ else:
96
+ raise RuntimeError(f"Codex version command failed: {result.stderr}")
97
+
98
+ except (subprocess.TimeoutExpired, FileNotFoundError, RuntimeError) as e:
99
+ logger.warning("codex_version_detection_failed", error=str(e))
100
+ return "unknown"
101
+
102
+ async def _detect_codex_headers(self, version: str) -> CodexCacheData:
103
+ """Execute Codex CLI with proxy to capture headers and instructions."""
104
+ # Data captured from the request
105
+ captured_data: dict[str, Any] = {}
106
+
107
+ async def capture_handler(request: Request) -> Response:
108
+ """Capture the Codex CLI request."""
109
+ captured_data["headers"] = dict(request.headers)
110
+ captured_data["body"] = await request.body()
111
+ # Return a mock response to satisfy Codex CLI
112
+ return Response(
113
+ content='{"choices": [{"message": {"content": "Test response"}}]}',
114
+ media_type="application/json",
115
+ status_code=200,
116
+ )
117
+
118
+ # Create temporary FastAPI app
119
+ temp_app = FastAPI()
120
+ temp_app.post("/backend-api/codex/responses")(capture_handler)
121
+
122
+ # Find available port
123
+ sock = socket.socket()
124
+ sock.bind(("", 0))
125
+ port = sock.getsockname()[1]
126
+ sock.close()
127
+
128
+ # Start server in background
129
+ from uvicorn import Config, Server
130
+
131
+ config = Config(temp_app, host="127.0.0.1", port=port, log_level="error")
132
+ server = Server(config)
133
+
134
+ logger.debug("start")
135
+ server_task = asyncio.create_task(server.serve())
136
+
137
+ try:
138
+ # Wait for server to start
139
+ await asyncio.sleep(0.5)
140
+
141
+ # Execute Codex CLI with proxy
142
+ env = {
143
+ **dict(os.environ),
144
+ "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/backend-api/codex",
145
+ }
146
+
147
+ process = await asyncio.create_subprocess_exec(
148
+ "codex",
149
+ "exec",
150
+ "test",
151
+ env=env,
152
+ stdout=asyncio.subprocess.PIPE,
153
+ stderr=asyncio.subprocess.PIPE,
154
+ )
155
+ # stderr = ""
156
+ # if process.stderr:
157
+ # stderr = await process.stderr.read(128)
158
+ # stdout = ""
159
+ # if process.stdout:
160
+ # stdout = await process.stdout.read(128)
161
+ # logger.warning("rcecdy", stderr=stderr, stdout=stdout)
162
+
163
+ # Wait for process with timeout
164
+ try:
165
+ await asyncio.wait_for(process.wait(), timeout=300)
166
+ except TimeoutError:
167
+ process.kill()
168
+ await process.wait()
169
+
170
+ # Stop server
171
+ server.should_exit = True
172
+ await server_task
173
+
174
+ if not captured_data:
175
+ raise RuntimeError("Failed to capture Codex CLI request")
176
+
177
+ # Extract headers and instructions
178
+ headers = self._extract_headers(captured_data["headers"])
179
+ instructions = self._extract_instructions(captured_data["body"])
180
+
181
+ return CodexCacheData(
182
+ codex_version=version, headers=headers, instructions=instructions
183
+ )
184
+
185
+ except Exception as e:
186
+ # Ensure server is stopped
187
+ server.should_exit = True
188
+ if not server_task.done():
189
+ await server_task
190
+ raise
191
+
192
+ def _load_from_cache(self, version: str) -> CodexCacheData | None:
193
+ """Load cached data for specific Codex version."""
194
+ cache_file = self.cache_dir / f"codex_headers_{version}.json"
195
+
196
+ if not cache_file.exists():
197
+ return None
198
+
199
+ try:
200
+ with cache_file.open("r") as f:
201
+ data = json.load(f)
202
+ return CodexCacheData.model_validate(data)
203
+ except Exception:
204
+ return None
205
+
206
+ def _save_to_cache(self, data: CodexCacheData) -> None:
207
+ """Save detection data to cache."""
208
+ cache_file = self.cache_dir / f"codex_headers_{data.codex_version}.json"
209
+
210
+ try:
211
+ with cache_file.open("w") as f:
212
+ json.dump(data.model_dump(), f, indent=2, default=str)
213
+ logger.debug(
214
+ "cache_saved", file=str(cache_file), version=data.codex_version
215
+ )
216
+ except Exception as e:
217
+ logger.warning("cache_save_failed", file=str(cache_file), error=str(e))
218
+
219
+ def _extract_headers(self, headers: dict[str, str]) -> CodexHeaders:
220
+ """Extract Codex CLI headers from captured request."""
221
+ try:
222
+ return CodexHeaders.model_validate(headers)
223
+ except Exception as e:
224
+ logger.error("header_extraction_failed", error=str(e))
225
+ raise ValueError(f"Failed to extract required headers: {e}") from e
226
+
227
+ def _extract_instructions(self, body: bytes) -> CodexInstructionsData:
228
+ """Extract instructions from captured request body."""
229
+ try:
230
+ data = json.loads(body.decode("utf-8"))
231
+ instructions_content = data.get("instructions")
232
+
233
+ if instructions_content is None:
234
+ raise ValueError("No instructions field found in request body")
235
+
236
+ return CodexInstructionsData(instructions_field=instructions_content)
237
+
238
+ except Exception as e:
239
+ logger.error("instructions_extraction_failed", error=str(e))
240
+ raise ValueError(f"Failed to extract instructions: {e}") from e
241
+
242
+ def _get_fallback_data(self) -> CodexCacheData:
243
+ """Get fallback data when detection fails."""
244
+ logger.warning("using_fallback_codex_data")
245
+
246
+ # Load fallback data from package data file
247
+ package_data_file = (
248
+ Path(__file__).parent.parent / "data" / "codex_headers_fallback.json"
249
+ )
250
+ with package_data_file.open("r") as f:
251
+ fallback_data_dict = json.load(f)
252
+ return CodexCacheData.model_validate(fallback_data_dict)