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,482 @@
1
+ """OAuth client implementation for Anthropic OAuth flow."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import secrets
7
+ import time
8
+ import urllib.parse
9
+ import webbrowser
10
+ from datetime import UTC, datetime
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from threading import Thread
13
+ from typing import Any, Optional
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ import httpx
17
+ from structlog import get_logger
18
+
19
+ from ccproxy.auth.exceptions import OAuthCallbackError, OAuthLoginError
20
+ from ccproxy.auth.models import ClaudeCredentials, OAuthToken, UserProfile
21
+ from ccproxy.auth.oauth.models import OAuthTokenRequest, OAuthTokenResponse
22
+ from ccproxy.config.auth import OAuthSettings
23
+ from ccproxy.services.credentials.config import OAuthConfig
24
+
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ def _log_http_error_compact(operation: str, response: httpx.Response) -> None:
30
+ """Log HTTP error response in compact format.
31
+
32
+ Args:
33
+ operation: Description of the operation that failed
34
+ response: HTTP response object
35
+ """
36
+ import os
37
+
38
+ # Check if verbose API logging is enabled
39
+ verbose_api = os.environ.get("CCPROXY_VERBOSE_API", "false").lower() == "true"
40
+
41
+ if verbose_api:
42
+ # Full verbose logging
43
+ logger.error(
44
+ "http_operation_failed",
45
+ operation=operation,
46
+ status_code=response.status_code,
47
+ response_text=response.text,
48
+ )
49
+ else:
50
+ # Compact logging - truncate response body
51
+ response_text = response.text
52
+ if len(response_text) > 200:
53
+ response_preview = f"{response_text[:100]}...{response_text[-50:]}"
54
+ elif len(response_text) > 100:
55
+ response_preview = f"{response_text[:100]}..."
56
+ else:
57
+ response_preview = response_text
58
+
59
+ logger.error(
60
+ "http_operation_failed_compact",
61
+ operation=operation,
62
+ status_code=response.status_code,
63
+ response_preview=response_preview,
64
+ verbose_hint="use CCPROXY_VERBOSE_API=true for full response",
65
+ )
66
+
67
+
68
+ class OAuthClient:
69
+ """OAuth client for handling Anthropic OAuth flows."""
70
+
71
+ def __init__(self, config: OAuthSettings | None = None):
72
+ """Initialize OAuth client.
73
+
74
+ Args:
75
+ config: OAuth configuration, uses default if not provided
76
+ """
77
+ self.config = config or OAuthConfig()
78
+
79
+ def generate_pkce_pair(self) -> tuple[str, str]:
80
+ """Generate PKCE code verifier and challenge pair.
81
+
82
+ Returns:
83
+ Tuple of (code_verifier, code_challenge)
84
+ """
85
+ # Generate code verifier (43-128 characters, URL-safe)
86
+ code_verifier = secrets.token_urlsafe(96) # 128 base64url chars
87
+
88
+ # For now, use plain method (Anthropic supports this)
89
+ # In production, should use SHA256 method
90
+ code_challenge = code_verifier
91
+
92
+ return code_verifier, code_challenge
93
+
94
+ def build_authorization_url(self, state: str, code_challenge: str) -> str:
95
+ """Build authorization URL for OAuth flow.
96
+
97
+ Args:
98
+ state: State parameter for CSRF protection
99
+ code_challenge: PKCE code challenge
100
+
101
+ Returns:
102
+ Authorization URL
103
+ """
104
+ params = {
105
+ "response_type": "code",
106
+ "client_id": self.config.client_id,
107
+ "redirect_uri": self.config.redirect_uri,
108
+ "scope": " ".join(self.config.scopes),
109
+ "state": state,
110
+ "code_challenge": code_challenge,
111
+ "code_challenge_method": "plain", # Using plain for simplicity
112
+ }
113
+
114
+ query_string = urllib.parse.urlencode(params)
115
+ return f"{self.config.authorize_url}?{query_string}"
116
+
117
+ async def exchange_code_for_tokens(
118
+ self,
119
+ authorization_code: str,
120
+ code_verifier: str,
121
+ ) -> OAuthTokenResponse:
122
+ """Exchange authorization code for access tokens.
123
+
124
+ Args:
125
+ authorization_code: Authorization code from callback
126
+ code_verifier: PKCE code verifier
127
+
128
+ Returns:
129
+ Token response
130
+
131
+ Raises:
132
+ httpx.HTTPError: If token exchange fails
133
+ """
134
+ token_request = OAuthTokenRequest(
135
+ code=authorization_code,
136
+ redirect_uri=self.config.redirect_uri,
137
+ client_id=self.config.client_id,
138
+ code_verifier=code_verifier,
139
+ )
140
+
141
+ headers = {
142
+ "Content-Type": "application/json",
143
+ "anthropic-beta": self.config.beta_version,
144
+ "User-Agent": self.config.user_agent,
145
+ }
146
+
147
+ async with httpx.AsyncClient() as client:
148
+ response = await client.post(
149
+ self.config.token_url,
150
+ headers=headers,
151
+ json=token_request.model_dump(),
152
+ timeout=self.config.request_timeout,
153
+ )
154
+
155
+ if response.status_code != 200:
156
+ _log_http_error_compact("Token exchange", response)
157
+ response.raise_for_status()
158
+
159
+ data = response.json()
160
+ return OAuthTokenResponse.model_validate(data)
161
+
162
+ async def refresh_access_token(self, refresh_token: str) -> OAuthTokenResponse:
163
+ """Refresh access token using refresh token.
164
+
165
+ Args:
166
+ refresh_token: Refresh token
167
+
168
+ Returns:
169
+ New token response
170
+
171
+ Raises:
172
+ httpx.HTTPError: If token refresh fails
173
+ """
174
+ refresh_request = {
175
+ "grant_type": "refresh_token",
176
+ "refresh_token": refresh_token,
177
+ "client_id": self.config.client_id,
178
+ }
179
+
180
+ headers = {
181
+ "Content-Type": "application/json",
182
+ "anthropic-beta": self.config.beta_version,
183
+ "User-Agent": self.config.user_agent,
184
+ }
185
+
186
+ async with httpx.AsyncClient() as client:
187
+ response = await client.post(
188
+ self.config.token_url,
189
+ headers=headers,
190
+ json=refresh_request,
191
+ timeout=self.config.request_timeout,
192
+ )
193
+
194
+ if response.status_code != 200:
195
+ _log_http_error_compact("Token refresh", response)
196
+ response.raise_for_status()
197
+
198
+ data = response.json()
199
+ return OAuthTokenResponse.model_validate(data)
200
+
201
+ async def refresh_token(self, refresh_token: str) -> "OAuthToken":
202
+ """Refresh token using refresh token - compatibility method for tests.
203
+
204
+ Args:
205
+ refresh_token: Refresh token
206
+
207
+ Returns:
208
+ New OAuth token
209
+
210
+ Raises:
211
+ OAuthTokenRefreshError: If token refresh fails
212
+ """
213
+ from datetime import UTC, datetime
214
+
215
+ from ccproxy.auth.exceptions import OAuthTokenRefreshError
216
+ from ccproxy.auth.models import OAuthToken
217
+
218
+ try:
219
+ token_response = await self.refresh_access_token(refresh_token)
220
+
221
+ expires_in = (
222
+ token_response.expires_in if token_response.expires_in else 3600
223
+ )
224
+
225
+ # Convert to OAuthToken format expected by tests
226
+ expires_at_ms = int((datetime.now(UTC).timestamp() + expires_in) * 1000)
227
+
228
+ return OAuthToken(
229
+ accessToken=token_response.access_token,
230
+ refreshToken=token_response.refresh_token or refresh_token,
231
+ expiresAt=expires_at_ms,
232
+ scopes=token_response.scope.split() if token_response.scope else [],
233
+ subscriptionType="pro", # Default value
234
+ )
235
+ except Exception as e:
236
+ raise OAuthTokenRefreshError(f"Token refresh failed: {e}") from e
237
+
238
+ async def fetch_user_profile(self, access_token: str) -> UserProfile | None:
239
+ """Fetch user profile information using access token.
240
+
241
+ Args:
242
+ access_token: Valid OAuth access token
243
+
244
+ Returns:
245
+ User profile information
246
+
247
+ Raises:
248
+ httpx.HTTPError: If profile fetch fails
249
+ """
250
+ from ccproxy.auth.models import UserProfile
251
+
252
+ headers = {
253
+ "Authorization": f"Bearer {access_token}",
254
+ "anthropic-beta": self.config.beta_version,
255
+ "User-Agent": self.config.user_agent,
256
+ "Content-Type": "application/json",
257
+ }
258
+
259
+ # Use the profile url
260
+ async with httpx.AsyncClient() as client:
261
+ response = await client.get(
262
+ self.config.profile_url,
263
+ headers=headers,
264
+ timeout=self.config.request_timeout,
265
+ )
266
+
267
+ if response.status_code == 404:
268
+ # Userinfo endpoint not available - this is expected for some OAuth providers
269
+ logger.debug(
270
+ "userinfo_endpoint_unavailable", endpoint=self.config.profile_url
271
+ )
272
+ return None
273
+ elif response.status_code != 200:
274
+ _log_http_error_compact("Profile fetch", response)
275
+ response.raise_for_status()
276
+
277
+ data = response.json()
278
+ logger.debug("user_profile_fetched", endpoint=self.config.profile_url)
279
+ return UserProfile.model_validate(data)
280
+
281
+ async def login(self) -> ClaudeCredentials:
282
+ """Perform OAuth login flow.
283
+
284
+ Returns:
285
+ ClaudeCredentials with OAuth token
286
+
287
+ Raises:
288
+ OAuthLoginError: If login fails
289
+ OAuthCallbackError: If callback processing fails
290
+ """
291
+ # Generate state parameter for security
292
+ state = secrets.token_urlsafe(32)
293
+
294
+ # Generate PKCE parameters
295
+ code_verifier = secrets.token_urlsafe(32)
296
+ code_challenge = (
297
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
298
+ .decode()
299
+ .rstrip("=")
300
+ )
301
+
302
+ authorization_code = None
303
+ error = None
304
+
305
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
306
+ def do_GET(self) -> None: # noqa: N802
307
+ nonlocal authorization_code, error
308
+
309
+ # Ignore favicon requests
310
+ if self.path == "/favicon.ico":
311
+ self.send_response(404)
312
+ self.end_headers()
313
+ return
314
+
315
+ parsed_url = urlparse(self.path)
316
+ query_params = parse_qs(parsed_url.query)
317
+
318
+ # Check state parameter
319
+ received_state = query_params.get("state", [None])[0]
320
+
321
+ if received_state != state:
322
+ error = "Invalid state parameter"
323
+ self.send_response(400)
324
+ self.end_headers()
325
+ self.wfile.write(b"Error: Invalid state parameter")
326
+ return
327
+
328
+ # Check for authorization code
329
+ if "code" in query_params:
330
+ authorization_code = query_params["code"][0]
331
+ self.send_response(200)
332
+ self.end_headers()
333
+ self.wfile.write(b"Login successful! You can close this window.")
334
+ elif "error" in query_params:
335
+ error = query_params.get("error_description", ["Unknown error"])[0]
336
+ self.send_response(400)
337
+ self.end_headers()
338
+ self.wfile.write(f"Error: {error}".encode())
339
+ else:
340
+ error = "No authorization code received"
341
+ self.send_response(400)
342
+ self.end_headers()
343
+ self.wfile.write(b"Error: No authorization code received")
344
+
345
+ def log_message(self, format: str, *args: Any) -> None:
346
+ # Suppress HTTP server logs
347
+ pass
348
+
349
+ # Start local HTTP server for OAuth callback
350
+ server = HTTPServer(
351
+ ("localhost", self.config.callback_port), OAuthCallbackHandler
352
+ )
353
+ server_thread = Thread(target=server.serve_forever)
354
+ server_thread.daemon = True
355
+ server_thread.start()
356
+
357
+ try:
358
+ # Build authorization URL
359
+ auth_params = {
360
+ "response_type": "code",
361
+ "client_id": self.config.client_id,
362
+ "redirect_uri": self.config.redirect_uri,
363
+ "scope": " ".join(self.config.scopes),
364
+ "state": state,
365
+ "code_challenge": code_challenge,
366
+ "code_challenge_method": "S256",
367
+ }
368
+
369
+ auth_url = (
370
+ f"{self.config.authorize_url}?{urllib.parse.urlencode(auth_params)}"
371
+ )
372
+
373
+ logger.info("oauth_browser_opening", auth_url=auth_url)
374
+ logger.info(
375
+ "oauth_manual_url",
376
+ message="If browser doesn't open, visit this URL",
377
+ auth_url=auth_url,
378
+ )
379
+
380
+ # Open browser
381
+ webbrowser.open(auth_url)
382
+
383
+ # Wait for callback (with timeout)
384
+ import time
385
+
386
+ start_time = time.time()
387
+
388
+ while authorization_code is None and error is None:
389
+ if time.time() - start_time > self.config.callback_timeout:
390
+ error = "Login timeout"
391
+ break
392
+ await asyncio.sleep(0.1)
393
+
394
+ if error:
395
+ raise OAuthCallbackError(f"OAuth callback failed: {error}")
396
+
397
+ if not authorization_code:
398
+ raise OAuthLoginError("No authorization code received")
399
+
400
+ # Exchange authorization code for tokens
401
+ token_data = {
402
+ "grant_type": "authorization_code",
403
+ "code": authorization_code,
404
+ "redirect_uri": self.config.redirect_uri,
405
+ "client_id": self.config.client_id,
406
+ "code_verifier": code_verifier,
407
+ "state": state,
408
+ }
409
+
410
+ headers = {
411
+ "Content-Type": "application/json",
412
+ "anthropic-beta": self.config.beta_version,
413
+ "User-Agent": self.config.user_agent,
414
+ }
415
+
416
+ async with httpx.AsyncClient() as client:
417
+ response = await client.post(
418
+ self.config.token_url,
419
+ headers=headers,
420
+ json=token_data,
421
+ timeout=30.0,
422
+ )
423
+
424
+ if response.status_code == 200:
425
+ result = response.json()
426
+
427
+ # Calculate expires_at from expires_in
428
+ expires_in = result.get("expires_in")
429
+ expires_at = None
430
+ if expires_in:
431
+ expires_at = int(
432
+ (datetime.now(UTC).timestamp() + expires_in) * 1000
433
+ )
434
+
435
+ # Create credentials object
436
+ oauth_data = {
437
+ "accessToken": result.get("access_token"),
438
+ "refreshToken": result.get("refresh_token"),
439
+ "expiresAt": expires_at,
440
+ "scopes": result.get("scope", "").split()
441
+ if result.get("scope")
442
+ else self.config.scopes,
443
+ "subscriptionType": result.get("subscription_type", "unknown"),
444
+ }
445
+
446
+ credentials = ClaudeCredentials(claudeAiOauth=OAuthToken(**oauth_data))
447
+
448
+ logger.info("oauth_login_completed", client_id=self.config.client_id)
449
+ return credentials
450
+
451
+ else:
452
+ # Use compact logging for the error message
453
+ import os
454
+
455
+ verbose_api = (
456
+ os.environ.get("CCPROXY_VERBOSE_API", "false").lower() == "true"
457
+ )
458
+
459
+ if verbose_api:
460
+ error_detail = response.text
461
+ else:
462
+ response_text = response.text
463
+ if len(response_text) > 200:
464
+ error_detail = f"{response_text[:100]}...{response_text[-50:]}"
465
+ elif len(response_text) > 100:
466
+ error_detail = f"{response_text[:100]}..."
467
+ else:
468
+ error_detail = response_text
469
+
470
+ raise OAuthLoginError(
471
+ f"Token exchange failed: {response.status_code} - {error_detail}"
472
+ )
473
+
474
+ except Exception as e:
475
+ if isinstance(e, OAuthLoginError | OAuthCallbackError):
476
+ raise
477
+ raise OAuthLoginError(f"OAuth login failed: {e}") from e
478
+
479
+ finally:
480
+ # Stop the HTTP server
481
+ server.shutdown()
482
+ server_thread.join(timeout=1)