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,334 @@
1
+ """OpenAI OAuth PKCE client implementation."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import contextlib
6
+ import hashlib
7
+ import secrets
8
+ import urllib.parse
9
+ import webbrowser
10
+ from datetime import UTC, datetime, timedelta
11
+
12
+ import httpx
13
+ import structlog
14
+ import uvicorn
15
+ from fastapi import FastAPI, Request, Response
16
+ from fastapi.responses import HTMLResponse
17
+
18
+ from ccproxy.config.codex import CodexSettings
19
+
20
+ from .credentials import OpenAICredentials, OpenAITokenManager
21
+
22
+
23
+ logger = structlog.get_logger(__name__)
24
+
25
+
26
+ class OpenAIOAuthClient:
27
+ """OpenAI OAuth PKCE flow client."""
28
+
29
+ def __init__(
30
+ self, settings: CodexSettings, token_manager: OpenAITokenManager | None = None
31
+ ):
32
+ """Initialize OAuth client.
33
+
34
+ Args:
35
+ settings: Codex configuration settings
36
+ token_manager: Token manager for credential storage
37
+ """
38
+ self.settings = settings
39
+ self.token_manager = token_manager or OpenAITokenManager()
40
+ self._server_task: asyncio.Task[None] | None = None
41
+ self._auth_complete = asyncio.Event()
42
+ self._auth_result: OpenAICredentials | None = None
43
+ self._auth_error: str | None = None
44
+
45
+ def _generate_pkce_pair(self) -> tuple[str, str]:
46
+ """Generate PKCE code verifier and challenge."""
47
+ # Generate code verifier (43-128 characters)
48
+ code_verifier = (
49
+ base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
50
+ )
51
+
52
+ # Generate code challenge
53
+ code_challenge = (
54
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
55
+ .decode()
56
+ .rstrip("=")
57
+ )
58
+
59
+ return code_verifier, code_challenge
60
+
61
+ def _build_auth_url(self, code_challenge: str, state: str) -> str:
62
+ """Build OAuth authorization URL."""
63
+ params = {
64
+ "response_type": "code",
65
+ "client_id": self.settings.oauth.client_id,
66
+ "redirect_uri": self.settings.get_redirect_uri(),
67
+ "scope": " ".join(self.settings.oauth.scopes),
68
+ "state": state,
69
+ "code_challenge": code_challenge,
70
+ "code_challenge_method": "S256",
71
+ }
72
+
73
+ query_string = urllib.parse.urlencode(params)
74
+ return f"{self.settings.oauth.base_url}/oauth/authorize?{query_string}"
75
+
76
+ async def _exchange_code_for_tokens(
77
+ self, code: str, code_verifier: str
78
+ ) -> OpenAICredentials:
79
+ """Exchange authorization code for tokens."""
80
+ token_url = f"{self.settings.oauth.base_url}/oauth/token"
81
+
82
+ data = {
83
+ "grant_type": "authorization_code",
84
+ "code": code,
85
+ "redirect_uri": self.settings.get_redirect_uri(),
86
+ "client_id": self.settings.oauth.client_id,
87
+ "code_verifier": code_verifier,
88
+ }
89
+
90
+ headers = {
91
+ "Content-Type": "application/x-www-form-urlencoded",
92
+ "Accept": "application/json",
93
+ }
94
+
95
+ async with httpx.AsyncClient() as client:
96
+ try:
97
+ response = await client.post(
98
+ token_url, data=data, headers=headers, timeout=30.0
99
+ )
100
+ response.raise_for_status()
101
+
102
+ token_data = response.json()
103
+
104
+ # Calculate expiration time
105
+ expires_in = token_data.get("expires_in", 3600) # Default 1 hour
106
+ expires_at = datetime.now(UTC).replace(microsecond=0) + timedelta(
107
+ seconds=expires_in
108
+ )
109
+
110
+ # Create credentials (account_id will be extracted from access_token)
111
+ credentials = OpenAICredentials(
112
+ access_token=token_data["access_token"],
113
+ refresh_token=token_data.get("refresh_token", ""),
114
+ expires_at=expires_at,
115
+ account_id="", # Will be auto-extracted by validator
116
+ active=True,
117
+ )
118
+
119
+ return credentials
120
+
121
+ except httpx.HTTPStatusError as e:
122
+ error_detail = "Unknown error"
123
+ try:
124
+ error_data = e.response.json()
125
+ error_detail = error_data.get(
126
+ "error_description", error_data.get("error", str(e))
127
+ )
128
+ except Exception:
129
+ error_detail = str(e)
130
+
131
+ raise ValueError(f"Token exchange failed: {error_detail}") from e
132
+ except Exception as e:
133
+ raise ValueError(f"Token exchange request failed: {e}") from e
134
+
135
+ def _create_callback_app(self, code_verifier: str, expected_state: str) -> FastAPI:
136
+ """Create FastAPI app to handle OAuth callback."""
137
+ app = FastAPI(title="OpenAI OAuth Callback")
138
+
139
+ @app.get("/auth/callback")
140
+ async def oauth_callback(request: Request) -> Response:
141
+ """Handle OAuth callback."""
142
+ params = dict(request.query_params)
143
+
144
+ # Check for error in callback
145
+ if "error" in params:
146
+ error_desc = params.get("error_description", params["error"])
147
+ self._auth_error = f"OAuth error: {error_desc}"
148
+ self._auth_complete.set()
149
+ return HTMLResponse(
150
+ """
151
+ <html>
152
+ <head><title>Authentication Failed</title></head>
153
+ <body>
154
+ <h1>Authentication Failed</h1>
155
+ <p>Error: """
156
+ + error_desc
157
+ + """</p>
158
+ <p>You can close this window.</p>
159
+ <script>setTimeout(() => window.close(), 3000);</script>
160
+ </body>
161
+ </html>
162
+ """,
163
+ status_code=400,
164
+ )
165
+
166
+ # Verify state parameter
167
+ received_state = params.get("state")
168
+ if received_state != expected_state:
169
+ self._auth_error = "Invalid state parameter"
170
+ self._auth_complete.set()
171
+ return HTMLResponse(
172
+ """
173
+ <html>
174
+ <head><title>Authentication Failed</title></head>
175
+ <body>
176
+ <h1>Authentication Failed</h1>
177
+ <p>Invalid state parameter. Possible CSRF attack.</p>
178
+ <p>You can close this window.</p>
179
+ <script>setTimeout(() => window.close(), 3000);</script>
180
+ </body>
181
+ </html>
182
+ """,
183
+ status_code=400,
184
+ )
185
+
186
+ # Get authorization code
187
+ auth_code = params.get("code")
188
+ if not auth_code:
189
+ self._auth_error = "No authorization code received"
190
+ self._auth_complete.set()
191
+ return HTMLResponse(
192
+ """
193
+ <html>
194
+ <head><title>Authentication Failed</title></head>
195
+ <body>
196
+ <h1>Authentication Failed</h1>
197
+ <p>No authorization code received.</p>
198
+ <p>You can close this window.</p>
199
+ <script>setTimeout(() => window.close(), 3000);</script>
200
+ </body>
201
+ </html>
202
+ """,
203
+ status_code=400,
204
+ )
205
+
206
+ # Exchange code for tokens
207
+ try:
208
+ credentials = await self._exchange_code_for_tokens(
209
+ auth_code, code_verifier
210
+ )
211
+
212
+ # Save credentials
213
+ success = await self.token_manager.save_credentials(credentials)
214
+ if not success:
215
+ raise ValueError("Failed to save credentials")
216
+
217
+ self._auth_result = credentials
218
+ self._auth_complete.set()
219
+
220
+ return HTMLResponse(
221
+ """
222
+ <html>
223
+ <head><title>Authentication Successful</title></head>
224
+ <body>
225
+ <h1>Authentication Successful!</h1>
226
+ <p>You have successfully authenticated with OpenAI.</p>
227
+ <p>You can close this window and return to the terminal.</p>
228
+ <script>setTimeout(() => window.close(), 3000);</script>
229
+ </body>
230
+ </html>
231
+ """
232
+ )
233
+
234
+ except Exception as e:
235
+ logger.error("Token exchange failed", error=str(e))
236
+ self._auth_error = f"Token exchange failed: {e}"
237
+ self._auth_complete.set()
238
+ return HTMLResponse(
239
+ f"""
240
+ <html>
241
+ <head><title>Authentication Failed</title></head>
242
+ <body>
243
+ <h1>Authentication Failed</h1>
244
+ <p>Token exchange failed: {e}</p>
245
+ <p>You can close this window.</p>
246
+ <script>setTimeout(() => window.close(), 3000);</script>
247
+ </body>
248
+ </html>
249
+ """,
250
+ status_code=500,
251
+ )
252
+
253
+ return app
254
+
255
+ async def _run_callback_server(self, app: FastAPI) -> None:
256
+ """Run callback server."""
257
+ config = uvicorn.Config(
258
+ app=app,
259
+ host="127.0.0.1",
260
+ port=self.settings.callback_port,
261
+ log_level="warning", # Reduce noise
262
+ access_log=False,
263
+ )
264
+ server = uvicorn.Server(config)
265
+ await server.serve()
266
+
267
+ async def authenticate(self, open_browser: bool = True) -> OpenAICredentials:
268
+ """Perform OAuth PKCE flow.
269
+
270
+ Args:
271
+ open_browser: Whether to automatically open browser
272
+
273
+ Returns:
274
+ OpenAI credentials
275
+
276
+ Raises:
277
+ ValueError: If authentication fails
278
+ """
279
+ # Reset state
280
+ self._auth_complete.clear()
281
+ self._auth_result = None
282
+ self._auth_error = None
283
+
284
+ # Generate PKCE parameters
285
+ code_verifier, code_challenge = self._generate_pkce_pair()
286
+ state = secrets.token_urlsafe(32)
287
+
288
+ # Create callback app
289
+ app = self._create_callback_app(code_verifier, state)
290
+
291
+ # Start callback server
292
+ self._server_task = asyncio.create_task(self._run_callback_server(app))
293
+
294
+ # Give server time to start
295
+ await asyncio.sleep(1)
296
+
297
+ # Build authorization URL
298
+ auth_url = self._build_auth_url(code_challenge, state)
299
+
300
+ logger.info("Starting OpenAI OAuth flow")
301
+ print("\nPlease visit this URL to authenticate with OpenAI:")
302
+ print(f"{auth_url}\n")
303
+
304
+ if open_browser:
305
+ try:
306
+ webbrowser.open(auth_url)
307
+ print("Opening browser...")
308
+ except Exception as e:
309
+ logger.warning("Failed to open browser automatically", error=str(e))
310
+ print("Please copy and paste the URL above into your browser.")
311
+
312
+ print("Waiting for authentication to complete...")
313
+
314
+ try:
315
+ # Wait for authentication to complete (with timeout)
316
+ await asyncio.wait_for(self._auth_complete.wait(), timeout=300) # 5 minutes
317
+
318
+ if self._auth_error:
319
+ raise ValueError(self._auth_error)
320
+
321
+ if not self._auth_result:
322
+ raise ValueError("Authentication completed but no credentials received")
323
+
324
+ logger.info("OpenAI authentication successful") # type: ignore[unreachable]
325
+ return self._auth_result
326
+
327
+ except TimeoutError as e:
328
+ raise ValueError("Authentication timed out (5 minutes)") from e
329
+ finally:
330
+ # Clean up server
331
+ if self._server_task and not self._server_task.done():
332
+ self._server_task.cancel()
333
+ with contextlib.suppress(asyncio.CancelledError):
334
+ await self._server_task
@@ -0,0 +1,184 @@
1
+ """JSON file storage for OpenAI credentials using Codex format."""
2
+
3
+ import contextlib
4
+ import json
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import jwt
10
+ import structlog
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from .credentials import OpenAICredentials
15
+
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+
20
+ class OpenAITokenStorage:
21
+ """JSON file-based storage for OpenAI credentials using Codex format."""
22
+
23
+ def __init__(self, file_path: Path | None = None):
24
+ """Initialize storage with file path.
25
+
26
+ Args:
27
+ file_path: Path to JSON file. If None, uses ~/.codex/auth.json
28
+ """
29
+ self.file_path = file_path or Path.home() / ".codex" / "auth.json"
30
+
31
+ async def load(self) -> "OpenAICredentials | None":
32
+ """Load credentials from Codex JSON file."""
33
+ if not self.file_path.exists():
34
+ return None
35
+
36
+ try:
37
+ with self.file_path.open("r") as f:
38
+ data = json.load(f)
39
+
40
+ # Extract tokens section
41
+ tokens = data.get("tokens", {})
42
+ if not tokens:
43
+ logger.warning("No tokens section found in Codex auth file")
44
+ return None
45
+
46
+ # Get required fields
47
+ access_token = tokens.get("access_token")
48
+ refresh_token = tokens.get("refresh_token")
49
+ account_id = tokens.get("account_id")
50
+
51
+ if not access_token:
52
+ logger.warning("No access_token found in Codex auth file")
53
+ return None
54
+
55
+ # Extract expiration from JWT token
56
+ expires_at = self._extract_expiration_from_token(access_token)
57
+ if not expires_at:
58
+ logger.warning("Could not extract expiration from access token")
59
+ return None
60
+
61
+ # Import here to avoid circular import
62
+ from .credentials import OpenAICredentials
63
+
64
+ # Create credentials object
65
+ credentials_data = {
66
+ "access_token": access_token,
67
+ "refresh_token": refresh_token or "",
68
+ "expires_at": expires_at,
69
+ "account_id": account_id or "",
70
+ "active": True,
71
+ }
72
+
73
+ return OpenAICredentials.from_dict(credentials_data)
74
+
75
+ except Exception as e:
76
+ logger.error(
77
+ "Failed to load OpenAI credentials from Codex auth file",
78
+ file_path=str(self.file_path),
79
+ error=str(e),
80
+ )
81
+ return None
82
+
83
+ def _extract_expiration_from_token(self, access_token: str) -> datetime | None:
84
+ """Extract expiration time from JWT access token."""
85
+ try:
86
+ decoded = jwt.decode(access_token, options={"verify_signature": False})
87
+ exp_timestamp = decoded.get("exp")
88
+ if exp_timestamp:
89
+ return datetime.fromtimestamp(exp_timestamp, tz=UTC)
90
+ except Exception as e:
91
+ logger.warning("Failed to decode JWT token for expiration", error=str(e))
92
+ return None
93
+
94
+ async def save(self, credentials: "OpenAICredentials") -> bool:
95
+ """Save credentials to Codex JSON file."""
96
+ try:
97
+ # Create directory if it doesn't exist
98
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
99
+
100
+ # Load existing file or create new structure
101
+ existing_data: dict[str, Any] = {}
102
+ if self.file_path.exists():
103
+ try:
104
+ with self.file_path.open("r") as f:
105
+ existing_data = json.load(f)
106
+ except Exception:
107
+ logger.warning(
108
+ "Could not load existing auth file, creating new one"
109
+ )
110
+
111
+ # Prepare Codex JSON data structure
112
+ codex_data = {
113
+ "OPENAI_API_KEY": existing_data.get("OPENAI_API_KEY"),
114
+ "tokens": {
115
+ "id_token": existing_data.get("tokens", {}).get("id_token"),
116
+ "access_token": credentials.access_token,
117
+ "refresh_token": credentials.refresh_token,
118
+ "account_id": credentials.account_id,
119
+ },
120
+ "last_refresh": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
121
+ }
122
+
123
+ # Write atomically by writing to temp file then renaming
124
+ temp_file = self.file_path.with_suffix(f"{self.file_path.suffix}.tmp")
125
+
126
+ with temp_file.open("w") as f:
127
+ json.dump(codex_data, f, indent=2)
128
+
129
+ # Set restrictive permissions (readable only by owner)
130
+ temp_file.chmod(0o600)
131
+
132
+ # Atomic rename
133
+ temp_file.replace(self.file_path)
134
+
135
+ logger.info(
136
+ "Saved OpenAI credentials to Codex auth file",
137
+ file_path=str(self.file_path),
138
+ )
139
+ return True
140
+
141
+ except Exception as e:
142
+ logger.error(
143
+ "Failed to save OpenAI credentials to Codex auth file",
144
+ file_path=str(self.file_path),
145
+ error=str(e),
146
+ )
147
+ # Clean up temp file if it exists
148
+ temp_file = self.file_path.with_suffix(f"{self.file_path.suffix}.tmp")
149
+ if temp_file.exists():
150
+ with contextlib.suppress(Exception):
151
+ temp_file.unlink()
152
+ return False
153
+
154
+ async def exists(self) -> bool:
155
+ """Check if credentials file exists."""
156
+ if not self.file_path.exists():
157
+ return False
158
+
159
+ try:
160
+ with self.file_path.open("r") as f:
161
+ data = json.load(f)
162
+ tokens = data.get("tokens", {})
163
+ return bool(tokens.get("access_token"))
164
+ except Exception:
165
+ return False
166
+
167
+ async def delete(self) -> bool:
168
+ """Delete credentials file."""
169
+ try:
170
+ if self.file_path.exists():
171
+ self.file_path.unlink()
172
+ logger.info("Deleted Codex auth file", file_path=str(self.file_path))
173
+ return True
174
+ except Exception as e:
175
+ logger.error(
176
+ "Failed to delete Codex auth file",
177
+ file_path=str(self.file_path),
178
+ error=str(e),
179
+ )
180
+ return False
181
+
182
+ def get_location(self) -> str:
183
+ """Get storage location description."""
184
+ return str(self.file_path)
@@ -61,7 +61,7 @@ class OptionsHandler:
61
61
  # Extract configuration values with proper types
62
62
  mcp_servers = (
63
63
  configured_opts.mcp_servers.copy()
64
- if configured_opts.mcp_servers
64
+ if isinstance(configured_opts.mcp_servers, dict)
65
65
  else {}
66
66
  )
67
67
  permission_prompt_tool_name = configured_opts.permission_prompt_tool_name