ccproxy-api 0.1.0__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 (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,396 @@
1
+ """OAuth authentication routes for Anthropic OAuth login."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, HTTPException, Query, Request
9
+ from fastapi.responses import HTMLResponse
10
+ from structlog import get_logger
11
+
12
+ from ccproxy.auth.models import (
13
+ ClaudeCredentials,
14
+ OAuthToken,
15
+ )
16
+ from ccproxy.auth.storage import JsonFileTokenStorage as JsonFileStorage
17
+
18
+ # Import CredentialsManager locally to avoid circular import
19
+ from ccproxy.services.credentials.config import OAuthConfig
20
+
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ router = APIRouter(tags=["oauth"])
25
+
26
+ # Store for pending OAuth flows
27
+ _pending_flows: dict[str, dict[str, Any]] = {}
28
+
29
+
30
+ def register_oauth_flow(
31
+ state: str, code_verifier: str, custom_paths: list[Path] | None = None
32
+ ) -> None:
33
+ """Register a pending OAuth flow."""
34
+ _pending_flows[state] = {
35
+ "code_verifier": code_verifier,
36
+ "custom_paths": custom_paths,
37
+ "completed": False,
38
+ "success": False,
39
+ "error": None,
40
+ }
41
+ logger.debug("Registered OAuth flow", state=state, operation="register_oauth_flow")
42
+
43
+
44
+ def get_oauth_flow_result(state: str) -> dict[str, Any] | None:
45
+ """Get and remove OAuth flow result."""
46
+ return _pending_flows.pop(state, None)
47
+
48
+
49
+ @router.get("/callback")
50
+ async def oauth_callback(
51
+ request: Request,
52
+ code: str | None = Query(None, description="Authorization code"),
53
+ state: str | None = Query(None, description="State parameter"),
54
+ error: str | None = Query(None, description="OAuth error"),
55
+ error_description: str | None = Query(None, description="OAuth error description"),
56
+ ) -> HTMLResponse:
57
+ """Handle OAuth callback from Claude authentication.
58
+
59
+ This endpoint receives the authorization code from Claude's OAuth flow
60
+ and exchanges it for access tokens.
61
+ """
62
+ try:
63
+ if error:
64
+ error_msg = error_description or error or "OAuth authentication failed"
65
+ logger.error(
66
+ "OAuth callback error",
67
+ error_type="oauth_error",
68
+ error_message=error_msg,
69
+ oauth_error=error,
70
+ oauth_error_description=error_description,
71
+ state=state,
72
+ operation="oauth_callback",
73
+ )
74
+
75
+ # Update pending flow if state is provided
76
+ if state and state in _pending_flows:
77
+ _pending_flows[state].update(
78
+ {
79
+ "completed": True,
80
+ "success": False,
81
+ "error": error_msg,
82
+ }
83
+ )
84
+
85
+ return HTMLResponse(
86
+ content=f"""
87
+ <html>
88
+ <head><title>Login Failed</title></head>
89
+ <body>
90
+ <h1>Login Failed</h1>
91
+ <p>Error: {error_msg}</p>
92
+ <p>You can close this window and try again.</p>
93
+ </body>
94
+ </html>
95
+ """,
96
+ status_code=400,
97
+ )
98
+
99
+ if not code:
100
+ error_msg = "No authorization code received"
101
+ logger.error(
102
+ "OAuth callback missing authorization code",
103
+ error_type="missing_code",
104
+ error_message=error_msg,
105
+ state=state,
106
+ operation="oauth_callback",
107
+ )
108
+
109
+ if state and state in _pending_flows:
110
+ _pending_flows[state].update(
111
+ {
112
+ "completed": True,
113
+ "success": False,
114
+ "error": error_msg,
115
+ }
116
+ )
117
+
118
+ return HTMLResponse(
119
+ content=f"""
120
+ <html>
121
+ <head><title>Login Failed</title></head>
122
+ <body>
123
+ <h1>Login Failed</h1>
124
+ <p>Error: {error_msg}</p>
125
+ <p>You can close this window and try again.</p>
126
+ </body>
127
+ </html>
128
+ """,
129
+ status_code=400,
130
+ )
131
+
132
+ if not state:
133
+ error_msg = "Missing state parameter"
134
+ logger.error(
135
+ "OAuth callback missing state parameter",
136
+ error_type="missing_state",
137
+ error_message=error_msg,
138
+ operation="oauth_callback",
139
+ )
140
+ return HTMLResponse(
141
+ content=f"""
142
+ <html>
143
+ <head><title>Login Failed</title></head>
144
+ <body>
145
+ <h1>Login Failed</h1>
146
+ <p>Error: {error_msg}</p>
147
+ <p>You can close this window and try again.</p>
148
+ </body>
149
+ </html>
150
+ """,
151
+ status_code=400,
152
+ )
153
+
154
+ # Check if this is a valid pending flow
155
+ if state not in _pending_flows:
156
+ error_msg = "Invalid or expired state parameter"
157
+ logger.error(
158
+ "OAuth callback with invalid state",
159
+ error_type="invalid_state",
160
+ error_message="Invalid or expired state parameter",
161
+ state=state,
162
+ operation="oauth_callback",
163
+ )
164
+ return HTMLResponse(
165
+ content=f"""
166
+ <html>
167
+ <head><title>Login Failed</title></head>
168
+ <body>
169
+ <h1>Login Failed</h1>
170
+ <p>Error: {error_msg}</p>
171
+ <p>You can close this window and try again.</p>
172
+ </body>
173
+ </html>
174
+ """,
175
+ status_code=400,
176
+ )
177
+
178
+ # Get flow details
179
+ flow = _pending_flows[state]
180
+ code_verifier = flow["code_verifier"]
181
+ custom_paths = flow["custom_paths"]
182
+
183
+ # Exchange authorization code for tokens
184
+ success = await _exchange_code_for_tokens(code, code_verifier, custom_paths)
185
+
186
+ # Update flow result
187
+ _pending_flows[state].update(
188
+ {
189
+ "completed": True,
190
+ "success": success,
191
+ "error": None if success else "Token exchange failed",
192
+ }
193
+ )
194
+
195
+ if success:
196
+ logger.info(
197
+ "OAuth login successful", state=state, operation="oauth_callback"
198
+ )
199
+ return HTMLResponse(
200
+ content="""
201
+ <html>
202
+ <head><title>Login Successful</title></head>
203
+ <body>
204
+ <h1>Login Successful!</h1>
205
+ <p>You have successfully logged in to Claude.</p>
206
+ <p>You can close this window and return to the CLI.</p>
207
+ <script>
208
+ setTimeout(() => {
209
+ window.close();
210
+ }, 3000);
211
+ </script>
212
+ </body>
213
+ </html>
214
+ """,
215
+ status_code=200,
216
+ )
217
+ else:
218
+ error_msg = "Failed to exchange authorization code for tokens"
219
+ logger.error(
220
+ "OAuth token exchange failed",
221
+ error_type="token_exchange_failed",
222
+ error_message=error_msg,
223
+ state=state,
224
+ operation="oauth_callback",
225
+ )
226
+ return HTMLResponse(
227
+ content=f"""
228
+ <html>
229
+ <head><title>Login Failed</title></head>
230
+ <body>
231
+ <h1>Login Failed</h1>
232
+ <p>Error: {error_msg}</p>
233
+ <p>You can close this window and try again.</p>
234
+ </body>
235
+ </html>
236
+ """,
237
+ status_code=500,
238
+ )
239
+
240
+ except Exception as e:
241
+ logger.error(
242
+ "Unexpected error in OAuth callback",
243
+ error_type="unexpected_error",
244
+ error_message=str(e),
245
+ state=state,
246
+ operation="oauth_callback",
247
+ exc_info=True,
248
+ )
249
+
250
+ if state and state in _pending_flows:
251
+ _pending_flows[state].update(
252
+ {
253
+ "completed": True,
254
+ "success": False,
255
+ "error": str(e),
256
+ }
257
+ )
258
+
259
+ return HTMLResponse(
260
+ content=f"""
261
+ <html>
262
+ <head><title>Login Error</title></head>
263
+ <body>
264
+ <h1>Login Error</h1>
265
+ <p>An unexpected error occurred: {str(e)}</p>
266
+ <p>You can close this window and try again.</p>
267
+ </body>
268
+ </html>
269
+ """,
270
+ status_code=500,
271
+ )
272
+
273
+
274
+ async def _exchange_code_for_tokens(
275
+ authorization_code: str, code_verifier: str, custom_paths: list[Path] | None = None
276
+ ) -> bool:
277
+ """Exchange authorization code for access tokens."""
278
+ try:
279
+ from datetime import UTC, datetime
280
+
281
+ import httpx
282
+
283
+ # Create OAuth config with default values
284
+ oauth_config = OAuthConfig()
285
+
286
+ # Exchange authorization code for tokens
287
+ token_data = {
288
+ "grant_type": "authorization_code",
289
+ "code": authorization_code,
290
+ "redirect_uri": oauth_config.redirect_uri,
291
+ "client_id": oauth_config.client_id,
292
+ "code_verifier": code_verifier,
293
+ }
294
+
295
+ headers = {
296
+ "Content-Type": "application/json",
297
+ "anthropic-beta": oauth_config.beta_version,
298
+ "User-Agent": oauth_config.user_agent,
299
+ }
300
+
301
+ async with httpx.AsyncClient() as client:
302
+ response = await client.post(
303
+ oauth_config.token_url,
304
+ headers=headers,
305
+ json=token_data,
306
+ timeout=30.0,
307
+ )
308
+
309
+ if response.status_code == 200:
310
+ result = response.json()
311
+
312
+ # Calculate expires_at from expires_in
313
+ expires_in = result.get("expires_in")
314
+ expires_at = None
315
+ if expires_in:
316
+ expires_at = int(
317
+ (datetime.now(UTC).timestamp() + expires_in) * 1000
318
+ )
319
+
320
+ # Create credentials object
321
+ oauth_data = {
322
+ "accessToken": result.get("access_token"),
323
+ "refreshToken": result.get("refresh_token"),
324
+ "expiresAt": expires_at,
325
+ "scopes": result.get("scope", "").split()
326
+ if result.get("scope")
327
+ else oauth_config.scopes,
328
+ "subscriptionType": result.get("subscription_type", "unknown"),
329
+ }
330
+
331
+ credentials = ClaudeCredentials(claudeAiOauth=OAuthToken(**oauth_data))
332
+
333
+ # Save credentials using CredentialsManager (lazy import to avoid circular import)
334
+ from ccproxy.services.credentials.manager import CredentialsManager
335
+
336
+ if custom_paths:
337
+ # Use the first custom path for storage
338
+ storage = JsonFileStorage(custom_paths[0])
339
+ manager = CredentialsManager(storage=storage)
340
+ else:
341
+ manager = CredentialsManager()
342
+
343
+ if await manager.save(credentials):
344
+ logger.info(
345
+ "Successfully saved OAuth credentials",
346
+ subscription_type=oauth_data["subscriptionType"],
347
+ scopes=oauth_data["scopes"],
348
+ operation="exchange_code_for_tokens",
349
+ )
350
+ return True
351
+ else:
352
+ logger.error(
353
+ "Failed to save OAuth credentials",
354
+ error_type="save_credentials_failed",
355
+ operation="exchange_code_for_tokens",
356
+ )
357
+ return False
358
+
359
+ else:
360
+ # Use compact logging for the error message
361
+ import os
362
+
363
+ verbose_api = (
364
+ os.environ.get("CCPROXY_VERBOSE_API", "false").lower() == "true"
365
+ )
366
+
367
+ if verbose_api:
368
+ error_detail = response.text
369
+ else:
370
+ response_text = response.text
371
+ if len(response_text) > 200:
372
+ error_detail = f"{response_text[:100]}...{response_text[-50:]}"
373
+ elif len(response_text) > 100:
374
+ error_detail = f"{response_text[:100]}..."
375
+ else:
376
+ error_detail = response_text
377
+
378
+ logger.error(
379
+ "Token exchange failed",
380
+ error_type="token_exchange_failed",
381
+ status_code=response.status_code,
382
+ error_detail=error_detail,
383
+ verbose_api_enabled=verbose_api,
384
+ operation="exchange_code_for_tokens",
385
+ )
386
+ return False
387
+
388
+ except Exception as e:
389
+ logger.error(
390
+ "Error during token exchange",
391
+ error_type="token_exchange_exception",
392
+ error_message=str(e),
393
+ operation="exchange_code_for_tokens",
394
+ exc_info=True,
395
+ )
396
+ return False
File without changes
@@ -0,0 +1,12 @@
1
+ """Token storage implementations for authentication."""
2
+
3
+ from ccproxy.auth.storage.base import TokenStorage
4
+ from ccproxy.auth.storage.json_file import JsonFileTokenStorage
5
+ from ccproxy.auth.storage.keyring import KeyringTokenStorage
6
+
7
+
8
+ __all__ = [
9
+ "TokenStorage",
10
+ "JsonFileTokenStorage",
11
+ "KeyringTokenStorage",
12
+ ]
@@ -0,0 +1,57 @@
1
+ """Abstract base class for token storage."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ccproxy.auth.models import ClaudeCredentials
6
+
7
+
8
+ class TokenStorage(ABC):
9
+ """Abstract interface for token storage operations."""
10
+
11
+ @abstractmethod
12
+ async def load(self) -> ClaudeCredentials | None:
13
+ """Load credentials from storage.
14
+
15
+ Returns:
16
+ Parsed credentials if found and valid, None otherwise
17
+ """
18
+ pass
19
+
20
+ @abstractmethod
21
+ async def save(self, credentials: ClaudeCredentials) -> bool:
22
+ """Save credentials to storage.
23
+
24
+ Args:
25
+ credentials: Credentials to save
26
+
27
+ Returns:
28
+ True if saved successfully, False otherwise
29
+ """
30
+ pass
31
+
32
+ @abstractmethod
33
+ async def exists(self) -> bool:
34
+ """Check if credentials exist in storage.
35
+
36
+ Returns:
37
+ True if credentials exist, False otherwise
38
+ """
39
+ pass
40
+
41
+ @abstractmethod
42
+ async def delete(self) -> bool:
43
+ """Delete credentials from storage.
44
+
45
+ Returns:
46
+ True if deleted successfully, False otherwise
47
+ """
48
+ pass
49
+
50
+ @abstractmethod
51
+ def get_location(self) -> str:
52
+ """Get the storage location description.
53
+
54
+ Returns:
55
+ Human-readable description of where credentials are stored
56
+ """
57
+ pass
@@ -0,0 +1,159 @@
1
+ """JSON file storage implementation for token storage."""
2
+
3
+ import contextlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from structlog import get_logger
9
+
10
+ from ccproxy.auth.exceptions import (
11
+ CredentialsInvalidError,
12
+ CredentialsStorageError,
13
+ )
14
+ from ccproxy.auth.models import ClaudeCredentials
15
+ from ccproxy.auth.storage.base import TokenStorage
16
+
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class JsonFileTokenStorage(TokenStorage):
22
+ """JSON file storage implementation for Claude credentials with keyring fallback."""
23
+
24
+ def __init__(self, file_path: Path):
25
+ """Initialize JSON file storage.
26
+
27
+ Args:
28
+ file_path: Path to the JSON credentials file
29
+ """
30
+ self.file_path = file_path
31
+
32
+ async def load(self) -> ClaudeCredentials | None:
33
+ """Load credentials from JSON file .
34
+
35
+ Returns:
36
+ Parsed credentials if found and valid, None otherwise
37
+
38
+ Raises:
39
+ CredentialsInvalidError: If the JSON file is invalid
40
+ CredentialsStorageError: If there's an error reading the file
41
+ """
42
+ if not await self.exists():
43
+ logger.debug("credentials_file_not_found", path=str(self.file_path))
44
+ return None
45
+
46
+ try:
47
+ logger.debug(
48
+ "credentials_load_start", source="file", path=str(self.file_path)
49
+ )
50
+ with self.file_path.open() as f:
51
+ data = json.load(f)
52
+
53
+ credentials = ClaudeCredentials.model_validate(data)
54
+ logger.debug("credentials_load_completed", source="file")
55
+
56
+ return credentials
57
+
58
+ except json.JSONDecodeError as e:
59
+ raise CredentialsInvalidError(
60
+ f"Failed to parse credentials file {self.file_path}: {e}"
61
+ ) from e
62
+ except Exception as e:
63
+ raise CredentialsStorageError(
64
+ f"Error loading credentials from {self.file_path}: {e}"
65
+ ) from e
66
+
67
+ async def save(self, credentials: ClaudeCredentials) -> bool:
68
+ """Save credentials to both keyring and JSON file.
69
+
70
+ Args:
71
+ credentials: Credentials to save
72
+
73
+ Returns:
74
+ True if saved successfully, False otherwise
75
+
76
+ Raises:
77
+ CredentialsStorageError: If there's an error writing the file
78
+ """
79
+ try:
80
+ # Convert to dict with proper aliases
81
+ data = credentials.model_dump(by_alias=True, mode="json")
82
+
83
+ # Always save to file as well
84
+ # Ensure parent directory exists
85
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ # Use atomic write: write to temp file then rename
88
+ temp_path = self.file_path.with_suffix(".tmp")
89
+
90
+ try:
91
+ with temp_path.open("w") as f:
92
+ json.dump(data, f, indent=2)
93
+
94
+ # Set appropriate file permissions (read/write for owner only)
95
+ temp_path.chmod(0o600)
96
+
97
+ # Atomically replace the original file
98
+ Path.replace(temp_path, self.file_path)
99
+
100
+ logger.debug(
101
+ "credentials_save_completed",
102
+ source="file",
103
+ path=str(self.file_path),
104
+ )
105
+ return True
106
+ except Exception as e:
107
+ raise
108
+ finally:
109
+ # Clean up temp file if it exists
110
+ if temp_path.exists():
111
+ with contextlib.suppress(Exception):
112
+ temp_path.unlink()
113
+
114
+ except Exception as e:
115
+ raise CredentialsStorageError(f"Error saving credentials: {e}") from e
116
+
117
+ async def exists(self) -> bool:
118
+ """Check if credentials file exists.
119
+
120
+ Returns:
121
+ True if file exists, False otherwise
122
+ """
123
+ return self.file_path.exists() and self.file_path.is_file()
124
+
125
+ async def delete(self) -> bool:
126
+ """Delete credentials from both keyring and file.
127
+
128
+ Returns:
129
+ True if deleted successfully, False otherwise
130
+
131
+ Raises:
132
+ CredentialsStorageError: If there's an error deleting the file
133
+ """
134
+ deleted = False
135
+
136
+ # Delete from file
137
+ try:
138
+ if await self.exists():
139
+ self.file_path.unlink()
140
+ logger.debug(
141
+ "credentials_delete_completed",
142
+ source="file",
143
+ path=str(self.file_path),
144
+ )
145
+ deleted = True
146
+ except Exception as e:
147
+ if not deleted: # Only raise if we failed to delete from both
148
+ raise CredentialsStorageError(f"Error deleting credentials: {e}") from e
149
+ logger.debug("credentials_delete_partial", source="file", error=str(e))
150
+
151
+ return deleted
152
+
153
+ def get_location(self) -> str:
154
+ """Get the storage location description.
155
+
156
+ Returns:
157
+ Path to the JSON file with keyring info if available
158
+ """
159
+ return str(self.file_path)