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,105 @@
1
+ """Configuration for credentials and OAuth."""
2
+
3
+ import os
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ def _get_default_storage_paths() -> list[str]:
9
+ """Get default storage paths, with test override support."""
10
+ # Allow tests to override credential paths
11
+ if os.getenv("CCPROXY_TEST_MODE") == "true":
12
+ # Use a test-specific location that won't pollute real credentials
13
+ return [
14
+ "/tmp/ccproxy-test/.config/claude/.credentials.json",
15
+ "/tmp/ccproxy-test/.claude/.credentials.json",
16
+ ]
17
+
18
+ return [
19
+ "~/.config/claude/.credentials.json", # Alternative legacy location
20
+ "~/.claude/.credentials.json", # Legacy location
21
+ "~/.config/ccproxy/credentials.json", # location in app config
22
+ ]
23
+
24
+
25
+ class OAuthConfig(BaseModel):
26
+ """OAuth configuration settings."""
27
+
28
+ base_url: str = Field(
29
+ default="https://console.anthropic.com",
30
+ description="Base URL for OAuth API endpoints",
31
+ )
32
+ beta_version: str = Field(
33
+ default="oauth-2025-04-20",
34
+ description="OAuth beta version header",
35
+ )
36
+ token_url: str = Field(
37
+ default="https://console.anthropic.com/v1/oauth/token",
38
+ description="OAuth token endpoint URL",
39
+ )
40
+ authorize_url: str = Field(
41
+ default="https://claude.ai/oauth/authorize",
42
+ description="OAuth authorization endpoint URL",
43
+ )
44
+ profile_url: str = Field(
45
+ default="https://api.anthropic.com/api/oauth/profile",
46
+ description="OAuth profile endpoint URL",
47
+ )
48
+ client_id: str = Field(
49
+ default="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
50
+ description="OAuth client ID",
51
+ )
52
+ redirect_uri: str = Field(
53
+ default="http://localhost:54545/callback",
54
+ description="OAuth redirect URI",
55
+ )
56
+ scopes: list[str] = Field(
57
+ default_factory=lambda: [
58
+ "org:create_api_key",
59
+ "user:profile",
60
+ "user:inference",
61
+ ],
62
+ description="OAuth scopes to request",
63
+ )
64
+ request_timeout: int = Field(
65
+ default=30,
66
+ description="Timeout in seconds for OAuth requests",
67
+ )
68
+ user_agent: str = Field(
69
+ default="Claude-Code/1.0.43",
70
+ description="User agent string for OAuth requests",
71
+ )
72
+ callback_timeout: int = Field(
73
+ default=300,
74
+ description="Timeout in seconds for OAuth callback",
75
+ ge=60,
76
+ le=600,
77
+ )
78
+ callback_port: int = Field(
79
+ default=54545,
80
+ description="Port for OAuth callback server",
81
+ ge=1024,
82
+ le=65535,
83
+ )
84
+
85
+
86
+ class CredentialsConfig(BaseModel):
87
+ """Configuration for credentials management."""
88
+
89
+ storage_paths: list[str] = Field(
90
+ default_factory=lambda: _get_default_storage_paths(),
91
+ description="Paths to search for credentials files",
92
+ )
93
+ oauth: OAuthConfig = Field(
94
+ default_factory=OAuthConfig,
95
+ description="OAuth configuration",
96
+ )
97
+ auto_refresh: bool = Field(
98
+ default=True,
99
+ description="Automatically refresh expired tokens",
100
+ )
101
+ refresh_buffer_seconds: int = Field(
102
+ default=300,
103
+ description="Refresh token this many seconds before expiry",
104
+ ge=0,
105
+ )
@@ -0,0 +1,562 @@
1
+ """Credentials manager for coordinating storage and OAuth operations."""
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import UTC, datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from structlog import get_logger
11
+
12
+ from ccproxy.auth.exceptions import (
13
+ CredentialsExpiredError,
14
+ CredentialsNotFoundError,
15
+ )
16
+ from ccproxy.auth.models import (
17
+ ClaudeCredentials,
18
+ OAuthToken,
19
+ UserProfile,
20
+ ValidationResult,
21
+ )
22
+ from ccproxy.auth.storage import JsonFileTokenStorage as JsonFileStorage
23
+ from ccproxy.auth.storage import TokenStorage as CredentialsStorageBackend
24
+ from ccproxy.config.auth import AuthSettings
25
+ from ccproxy.services.credentials.config import CredentialsConfig
26
+ from ccproxy.services.credentials.oauth_client import OAuthClient
27
+
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ class CredentialsManager:
33
+ """Manager for Claude credentials with storage and OAuth support."""
34
+
35
+ # ==================== Initialization ====================
36
+
37
+ def __init__(
38
+ self,
39
+ config: AuthSettings | None = None,
40
+ storage: CredentialsStorageBackend | None = None,
41
+ oauth_client: OAuthClient | None = None,
42
+ http_client: httpx.AsyncClient | None = None,
43
+ ):
44
+ """Initialize credentials manager.
45
+
46
+ Args:
47
+ config: Credentials configuration (uses defaults if not provided)
48
+ storage: Storage backend (uses JSON file storage if not provided)
49
+ oauth_client: OAuth client (creates one if not provided)
50
+ http_client: HTTP client for OAuth operations
51
+ """
52
+ self.config = config or AuthSettings()
53
+ self._storage = storage
54
+ self._oauth_client = oauth_client
55
+ self._http_client = http_client
56
+ self._owns_http_client = http_client is None
57
+ self._refresh_lock = asyncio.Lock()
58
+
59
+ # Initialize OAuth client if not provided
60
+ if self._oauth_client is None:
61
+ self._oauth_client = OAuthClient(
62
+ config=self.config.oauth,
63
+ )
64
+
65
+ async def __aenter__(self) -> "CredentialsManager":
66
+ """Async context manager entry."""
67
+ if self._http_client is None:
68
+ self._http_client = httpx.AsyncClient()
69
+ return self
70
+
71
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
72
+ """Async context manager exit."""
73
+ if self._owns_http_client and self._http_client:
74
+ await self._http_client.aclose()
75
+
76
+ # ==================== Storage Operations ====================
77
+
78
+ @property
79
+ def storage(self) -> CredentialsStorageBackend:
80
+ """Get the storage backend, creating default if needed."""
81
+ if self._storage is None:
82
+ # Find first existing credentials file or use first path
83
+ existing_path = self._find_existing_path()
84
+ if existing_path:
85
+ self._storage = JsonFileStorage(existing_path)
86
+ else:
87
+ # Use first path as default
88
+ self._storage = JsonFileStorage(
89
+ Path(self.config.storage.storage_paths[0]).expanduser()
90
+ )
91
+ return self._storage
92
+
93
+ async def find_credentials_file(self) -> Path | None:
94
+ """Find existing credentials file in configured paths.
95
+
96
+ Returns:
97
+ Path to credentials file if found, None otherwise
98
+ """
99
+ for path_str in self.config.storage.storage_paths:
100
+ path = Path(path_str).expanduser()
101
+ logger.debug("checking_credentials_path", path=str(path))
102
+ if path.exists() and path.is_file():
103
+ logger.info("credentials_file_found", path=str(path))
104
+ return path
105
+ else:
106
+ logger.debug("credentials_path_not_found", path=str(path))
107
+
108
+ logger.warning(
109
+ "no_credentials_files_found",
110
+ searched_paths=self.config.storage.storage_paths,
111
+ )
112
+ return None
113
+
114
+ async def load(self) -> ClaudeCredentials | None:
115
+ """Load credentials from storage.
116
+
117
+ Returns:
118
+ Credentials if found and valid, None otherwise
119
+ """
120
+ try:
121
+ return await self.storage.load()
122
+ except Exception as e:
123
+ logger.error("credentials_load_failed", error=str(e))
124
+ return None
125
+
126
+ async def save(self, credentials: ClaudeCredentials) -> bool:
127
+ """Save credentials to storage.
128
+
129
+ Args:
130
+ credentials: Credentials to save
131
+
132
+ Returns:
133
+ True if saved successfully, False otherwise
134
+ """
135
+ try:
136
+ return await self.storage.save(credentials)
137
+ except Exception as e:
138
+ logger.error("credentials_save_failed", error=str(e))
139
+ return False
140
+
141
+ # ==================== OAuth Operations ====================
142
+
143
+ async def login(self) -> ClaudeCredentials:
144
+ """Perform OAuth login and save credentials.
145
+
146
+ Returns:
147
+ New credentials from login
148
+
149
+ Raises:
150
+ OAuthLoginError: If login fails
151
+ """
152
+ if self._oauth_client is None:
153
+ raise RuntimeError("OAuth client not initialized")
154
+ credentials = await self._oauth_client.login()
155
+
156
+ # Fetch and save user profile after successful login
157
+ try:
158
+ profile = await self._oauth_client.fetch_user_profile(
159
+ credentials.claude_ai_oauth.access_token
160
+ )
161
+ if profile:
162
+ # Save profile data
163
+ await self._save_account_profile(profile)
164
+
165
+ # Update subscription type based on profile
166
+ determined_subscription = self._determine_subscription_type(profile)
167
+ credentials.claude_ai_oauth.subscription_type = determined_subscription
168
+
169
+ logger.debug(
170
+ "subscription_type_set", subscription_type=determined_subscription
171
+ )
172
+ else:
173
+ logger.debug(
174
+ "profile_fetch_skipped", context="login", reason="no_profile_data"
175
+ )
176
+ except Exception as e:
177
+ logger.warning("profile_fetch_failed", context="login", error=str(e))
178
+ # Continue with login even if profile fetch fails
179
+
180
+ await self.save(credentials)
181
+ return credentials
182
+
183
+ async def get_valid_credentials(self) -> ClaudeCredentials:
184
+ """Get valid credentials, refreshing if necessary.
185
+
186
+ Returns:
187
+ Valid credentials
188
+
189
+ Raises:
190
+ CredentialsNotFoundError: If no credentials found
191
+ CredentialsExpiredError: If credentials expired and refresh fails
192
+ """
193
+ credentials = await self.load()
194
+ if not credentials:
195
+ raise CredentialsNotFoundError("No credentials found. Please login first.")
196
+
197
+ # Check if token needs refresh
198
+ oauth_token = credentials.claude_ai_oauth
199
+ should_refresh = self._should_refresh_token(oauth_token)
200
+
201
+ if should_refresh:
202
+ async with self._refresh_lock:
203
+ # Re-check if refresh is still needed after acquiring lock
204
+ # Another request might have already refreshed the token
205
+ credentials = await self.load()
206
+ if not credentials:
207
+ raise CredentialsNotFoundError(
208
+ "No credentials found. Please login first."
209
+ )
210
+
211
+ oauth_token = credentials.claude_ai_oauth
212
+ should_refresh = self._should_refresh_token(oauth_token)
213
+
214
+ if should_refresh:
215
+ logger.info(
216
+ "token_refresh_start", reason="expired_or_expiring_soon"
217
+ )
218
+ try:
219
+ credentials = await self._refresh_token_with_profile(
220
+ credentials
221
+ )
222
+ except Exception as e:
223
+ logger.error(
224
+ "token_refresh_failed", error=str(e), exc_info=True
225
+ )
226
+ if oauth_token.is_expired:
227
+ raise CredentialsExpiredError(
228
+ "Token expired and refresh failed. Please login again."
229
+ ) from e
230
+ # If not expired yet but refresh failed, return existing token
231
+ logger.warning(
232
+ "token_refresh_fallback",
233
+ reason="refresh_failed_but_token_not_expired",
234
+ )
235
+
236
+ return credentials
237
+
238
+ async def get_access_token(self) -> str:
239
+ """Get valid access token, refreshing if necessary.
240
+
241
+ Returns:
242
+ Access token string
243
+
244
+ Raises:
245
+ CredentialsNotFoundError: If no credentials found
246
+ CredentialsExpiredError: If credentials expired and refresh fails
247
+ """
248
+ credentials = await self.get_valid_credentials()
249
+ return credentials.claude_ai_oauth.access_token
250
+
251
+ async def refresh_token(self) -> ClaudeCredentials:
252
+ """Refresh the access token without checking expiration.
253
+
254
+ This method directly refreshes the token regardless of whether it's expired.
255
+ Useful for force-refreshing tokens or testing.
256
+
257
+ Returns:
258
+ Updated credentials with new token
259
+
260
+ Raises:
261
+ CredentialsNotFoundError: If no credentials found
262
+ RuntimeError: If OAuth client not initialized
263
+ ValueError: If no refresh token available
264
+ Exception: If token refresh fails
265
+ """
266
+ credentials = await self.load()
267
+ if not credentials:
268
+ raise CredentialsNotFoundError("No credentials found. Please login first.")
269
+
270
+ logger.info("token_refresh_start", reason="forced")
271
+ return await self._refresh_token_with_profile(credentials)
272
+
273
+ async def fetch_user_profile(self) -> UserProfile | None:
274
+ """Fetch user profile information.
275
+
276
+ Returns:
277
+ UserProfile if successful, None otherwise
278
+ """
279
+ try:
280
+ credentials = await self.get_valid_credentials()
281
+ if self._oauth_client is None:
282
+ raise RuntimeError("OAuth client not initialized")
283
+ profile = await self._oauth_client.fetch_user_profile(
284
+ credentials.claude_ai_oauth.access_token,
285
+ )
286
+ return profile
287
+ except Exception as e:
288
+ logger.error(
289
+ "user_profile_fetch_failed",
290
+ error=str(e),
291
+ exc_info=True,
292
+ )
293
+ return None
294
+
295
+ async def get_account_profile(self) -> UserProfile | None:
296
+ """Get saved account profile information.
297
+
298
+ Returns:
299
+ UserProfile if available, None otherwise
300
+ """
301
+ return await self._load_account_profile()
302
+
303
+ # ==================== Validation and Management ====================
304
+
305
+ async def validate(self) -> ValidationResult:
306
+ """Validate current credentials.
307
+
308
+ Returns:
309
+ ValidationResult with credentials status and details
310
+ """
311
+ credentials = await self.load()
312
+ if not credentials:
313
+ raise CredentialsNotFoundError()
314
+
315
+ return ValidationResult(
316
+ valid=True,
317
+ expired=credentials.claude_ai_oauth.is_expired,
318
+ credentials=credentials,
319
+ path=self.storage.get_location(),
320
+ )
321
+
322
+ async def logout(self) -> bool:
323
+ """Delete stored credentials.
324
+
325
+ Returns:
326
+ True if deleted successfully, False otherwise
327
+ """
328
+ try:
329
+ # Delete both credentials and account profile
330
+ success = await self.storage.delete()
331
+ await self._delete_account_profile()
332
+ return success
333
+ except Exception as e:
334
+ logger.error("credentials_delete_failed", error=str(e), exc_info=True)
335
+ return False
336
+
337
+ # ==================== Private Helper Methods ====================
338
+
339
+ async def _get_account_profile_path(self) -> Path:
340
+ """Get the path for account profile storage.
341
+
342
+ Returns:
343
+ Path to account.json file alongside credentials
344
+ """
345
+ # Use the same directory as credentials file but with account.json name
346
+ credentials_path = self._find_existing_path()
347
+ if credentials_path is None:
348
+ # Use first path as default
349
+ credentials_path = Path(self.config.storage.storage_paths[0]).expanduser()
350
+
351
+ # Replace filename with account.json
352
+ return credentials_path.parent / "account.json"
353
+
354
+ async def _save_account_profile(self, profile: UserProfile) -> bool:
355
+ """Save account profile to account.json.
356
+
357
+ Args:
358
+ profile: User profile to save
359
+
360
+ Returns:
361
+ True if saved successfully
362
+ """
363
+ try:
364
+ account_path = await self._get_account_profile_path()
365
+ account_path.parent.mkdir(parents=True, exist_ok=True)
366
+
367
+ # Convert to dict and save as JSON
368
+ profile_data = profile.model_dump()
369
+
370
+ with account_path.open("w", encoding="utf-8") as f:
371
+ json.dump(profile_data, f, indent=2, ensure_ascii=False)
372
+
373
+ logger.debug("account_profile_saved", path=str(account_path))
374
+ return True
375
+
376
+ except Exception as e:
377
+ logger.error("account_profile_save_failed", error=str(e), exc_info=True)
378
+ return False
379
+
380
+ async def _load_account_profile(self) -> UserProfile | None:
381
+ """Load account profile from account.json.
382
+
383
+ Returns:
384
+ User profile if found, None otherwise
385
+ """
386
+ try:
387
+ account_path = await self._get_account_profile_path()
388
+
389
+ if not account_path.exists():
390
+ logger.debug("account_profile_not_found", path=str(account_path))
391
+ return None
392
+
393
+ with account_path.open("r", encoding="utf-8") as f:
394
+ profile_data = json.load(f)
395
+
396
+ return UserProfile.model_validate(profile_data)
397
+
398
+ except Exception as e:
399
+ logger.debug("account_profile_load_failed", error=str(e))
400
+ return None
401
+
402
+ async def _delete_account_profile(self) -> bool:
403
+ """Delete account profile file.
404
+
405
+ Returns:
406
+ True if deleted successfully
407
+ """
408
+ try:
409
+ account_path = await self._get_account_profile_path()
410
+ if account_path.exists():
411
+ account_path.unlink()
412
+ logger.debug("account_profile_deleted", path=str(account_path))
413
+ return True
414
+ except Exception as e:
415
+ logger.debug("account_profile_delete_failed", error=str(e))
416
+ return False
417
+
418
+ def _determine_subscription_type(self, profile: UserProfile) -> str:
419
+ """Determine subscription type from profile information.
420
+
421
+ Args:
422
+ profile: User profile with account information
423
+
424
+ Returns:
425
+ Subscription type string
426
+ """
427
+ if not profile.account:
428
+ return "unknown"
429
+
430
+ # Check account flags first
431
+ if profile.account.has_claude_max:
432
+ return "max"
433
+ elif profile.account.has_claude_pro:
434
+ return "pro"
435
+
436
+ # Fallback to organization type
437
+ if profile.organization and profile.organization.organization_type:
438
+ org_type = profile.organization.organization_type.lower()
439
+ if "max" in org_type:
440
+ return "max"
441
+ elif "pro" in org_type:
442
+ return "pro"
443
+
444
+ return "free"
445
+
446
+ def _find_existing_path(self) -> Path | None:
447
+ """Find first existing path from configured storage paths.
448
+
449
+ Returns:
450
+ Path if found, None otherwise
451
+ """
452
+ for path_str in self.config.storage.storage_paths:
453
+ path = Path(path_str).expanduser()
454
+ if path.exists():
455
+ return path
456
+ return None
457
+
458
+ def _should_refresh_token(self, oauth_token: OAuthToken) -> bool:
459
+ """Check if token should be refreshed based on configuration.
460
+
461
+ Args:
462
+ oauth_token: Token to check
463
+
464
+ Returns:
465
+ True if token should be refreshed
466
+ """
467
+ if self.config.storage.auto_refresh:
468
+ buffer = timedelta(seconds=self.config.storage.refresh_buffer_seconds)
469
+ return datetime.now(UTC) + buffer >= oauth_token.expires_at_datetime
470
+ else:
471
+ return oauth_token.is_expired
472
+
473
+ async def _refresh_token_with_profile(
474
+ self, credentials: ClaudeCredentials
475
+ ) -> ClaudeCredentials:
476
+ """Refresh token and update profile information.
477
+
478
+ Args:
479
+ credentials: Current credentials with token to refresh
480
+
481
+ Returns:
482
+ Updated credentials with new token and profile info
483
+
484
+ Raises:
485
+ RuntimeError: If OAuth client not initialized
486
+ ValueError: If no refresh token available
487
+ Exception: If token refresh fails
488
+ """
489
+ if self._oauth_client is None:
490
+ raise RuntimeError("OAuth client not initialized")
491
+
492
+ oauth_token = credentials.claude_ai_oauth
493
+
494
+ # Refresh the token
495
+ token_response = await self._oauth_client.refresh_access_token(
496
+ oauth_token.refresh_token
497
+ )
498
+
499
+ # Calculate expires_at from expires_in if provided
500
+ expires_at = oauth_token.expires_at # Start with existing value
501
+ if token_response.expires_in:
502
+ expires_at = int(
503
+ (datetime.now(UTC).timestamp() + token_response.expires_in) * 1000
504
+ )
505
+
506
+ # Parse scopes from server response
507
+ new_scopes = oauth_token.scopes # Start with existing scopes
508
+ if token_response.scope:
509
+ new_scopes = token_response.scope.split()
510
+
511
+ # Create new token preserving all server fields when available
512
+ # Ensure we have valid refresh token
513
+ if not token_response.refresh_token and not oauth_token.refresh_token:
514
+ raise ValueError("No refresh token available")
515
+
516
+ # Convert OAuthTokenResponse to OAuthToken format
517
+ new_token = OAuthToken(
518
+ accessToken=token_response.access_token,
519
+ refreshToken=token_response.refresh_token or oauth_token.refresh_token,
520
+ expiresAt=expires_at,
521
+ scopes=new_scopes,
522
+ subscriptionType=token_response.subscription_type
523
+ or oauth_token.subscription_type,
524
+ tokenType=token_response.token_type or oauth_token.token_type,
525
+ )
526
+
527
+ # Update credentials with new token
528
+ credentials.claude_ai_oauth = new_token
529
+
530
+ # Fetch user profile to update subscription type
531
+ try:
532
+ profile = await self._oauth_client.fetch_user_profile(
533
+ new_token.access_token
534
+ )
535
+ if profile:
536
+ # Save profile data
537
+ await self._save_account_profile(profile)
538
+
539
+ # Update subscription type based on profile
540
+ determined_subscription = self._determine_subscription_type(profile)
541
+ new_token.subscription_type = determined_subscription
542
+ credentials.claude_ai_oauth = new_token
543
+
544
+ logger.debug(
545
+ "subscription_type_updated",
546
+ subscription_type=determined_subscription,
547
+ )
548
+ else:
549
+ logger.debug(
550
+ "profile_fetch_skipped", reason="no_profile_data_available"
551
+ )
552
+ except Exception as e:
553
+ logger.warning(
554
+ "profile_fetch_failed", context="token_refresh", error=str(e)
555
+ )
556
+ # Continue with token refresh even if profile fetch fails
557
+
558
+ # Save updated credentials
559
+ await self.save(credentials)
560
+
561
+ logger.info("token_refresh_completed")
562
+ return credentials