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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/codex/__init__.py +11 -0
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/api/app.py +31 -3
- ccproxy/api/dependencies.py +1 -8
- ccproxy/api/middleware/errors.py +15 -7
- ccproxy/api/routes/codex.py +1251 -0
- ccproxy/api/routes/health.py +228 -3
- 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/options.py +1 -1
- ccproxy/cli/commands/auth.py +398 -1
- ccproxy/cli/commands/serve.py +3 -1
- ccproxy/config/claude.py +1 -1
- ccproxy/config/codex.py +100 -0
- ccproxy/config/scheduler.py +8 -8
- ccproxy/config/settings.py +19 -0
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +153 -2
- ccproxy/data/claude_headers_fallback.json +37 -0
- ccproxy/data/codex_headers_fallback.json +14 -0
- ccproxy/models/detection.py +82 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -0
- ccproxy/scheduler/manager.py +2 -2
- ccproxy/scheduler/tasks.py +105 -65
- ccproxy/services/claude_detection_service.py +7 -33
- ccproxy/services/codex_detection_service.py +252 -0
- ccproxy/services/proxy_service.py +530 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +205 -12
- ccproxy/utils/version_checker.py +6 -0
- ccproxy_api-0.1.7.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/RECORD +41 -28
- ccproxy_api-0.1.5.dist-info/METADATA +0 -396
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/entry_points.txt +0 -0
- {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)
|
ccproxy/claude_sdk/options.py
CHANGED
|
@@ -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
|