ccproxy-api 0.1.4__py3-none-any.whl → 0.1.6__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 (72) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/codex/__init__.py +11 -0
  3. ccproxy/adapters/openai/adapter.py +1 -1
  4. ccproxy/adapters/openai/models.py +1 -1
  5. ccproxy/adapters/openai/response_adapter.py +355 -0
  6. ccproxy/adapters/openai/response_models.py +178 -0
  7. ccproxy/adapters/openai/streaming.py +1 -0
  8. ccproxy/api/app.py +150 -224
  9. ccproxy/api/dependencies.py +22 -2
  10. ccproxy/api/middleware/errors.py +27 -3
  11. ccproxy/api/middleware/logging.py +4 -0
  12. ccproxy/api/responses.py +6 -1
  13. ccproxy/api/routes/claude.py +222 -17
  14. ccproxy/api/routes/codex.py +1231 -0
  15. ccproxy/api/routes/health.py +228 -3
  16. ccproxy/api/routes/proxy.py +25 -6
  17. ccproxy/api/services/permission_service.py +2 -2
  18. ccproxy/auth/openai/__init__.py +13 -0
  19. ccproxy/auth/openai/credentials.py +166 -0
  20. ccproxy/auth/openai/oauth_client.py +334 -0
  21. ccproxy/auth/openai/storage.py +184 -0
  22. ccproxy/claude_sdk/__init__.py +4 -8
  23. ccproxy/claude_sdk/client.py +661 -131
  24. ccproxy/claude_sdk/exceptions.py +16 -0
  25. ccproxy/claude_sdk/manager.py +219 -0
  26. ccproxy/claude_sdk/message_queue.py +342 -0
  27. ccproxy/claude_sdk/options.py +6 -1
  28. ccproxy/claude_sdk/session_client.py +546 -0
  29. ccproxy/claude_sdk/session_pool.py +550 -0
  30. ccproxy/claude_sdk/stream_handle.py +538 -0
  31. ccproxy/claude_sdk/stream_worker.py +392 -0
  32. ccproxy/claude_sdk/streaming.py +53 -11
  33. ccproxy/cli/commands/auth.py +398 -1
  34. ccproxy/cli/commands/serve.py +99 -1
  35. ccproxy/cli/options/claude_options.py +47 -0
  36. ccproxy/config/__init__.py +0 -3
  37. ccproxy/config/claude.py +171 -23
  38. ccproxy/config/codex.py +100 -0
  39. ccproxy/config/discovery.py +10 -1
  40. ccproxy/config/scheduler.py +2 -2
  41. ccproxy/config/settings.py +38 -1
  42. ccproxy/core/codex_transformers.py +389 -0
  43. ccproxy/core/http_transformers.py +458 -75
  44. ccproxy/core/logging.py +108 -12
  45. ccproxy/core/transformers.py +5 -0
  46. ccproxy/models/claude_sdk.py +57 -0
  47. ccproxy/models/detection.py +208 -0
  48. ccproxy/models/requests.py +22 -0
  49. ccproxy/models/responses.py +16 -0
  50. ccproxy/observability/access_logger.py +72 -14
  51. ccproxy/observability/metrics.py +151 -0
  52. ccproxy/observability/storage/duckdb_simple.py +12 -0
  53. ccproxy/observability/storage/models.py +16 -0
  54. ccproxy/observability/streaming_response.py +107 -0
  55. ccproxy/scheduler/manager.py +31 -6
  56. ccproxy/scheduler/tasks.py +122 -0
  57. ccproxy/services/claude_detection_service.py +269 -0
  58. ccproxy/services/claude_sdk_service.py +333 -130
  59. ccproxy/services/codex_detection_service.py +263 -0
  60. ccproxy/services/proxy_service.py +618 -197
  61. ccproxy/utils/__init__.py +9 -1
  62. ccproxy/utils/disconnection_monitor.py +83 -0
  63. ccproxy/utils/id_generator.py +12 -0
  64. ccproxy/utils/model_mapping.py +7 -5
  65. ccproxy/utils/startup_helpers.py +470 -0
  66. ccproxy_api-0.1.6.dist-info/METADATA +615 -0
  67. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
  68. ccproxy/config/loader.py +0 -105
  69. ccproxy_api-0.1.4.dist-info/METADATA +0 -369
  70. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
  71. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
  72. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/licenses/LICENSE +0 -0
@@ -41,6 +41,16 @@ class ClaudeCliStatus(str, Enum):
41
41
  ERROR = "error"
42
42
 
43
43
 
44
+ class CodexCliStatus(str, Enum):
45
+ """Codex CLI status enumeration."""
46
+
47
+ AVAILABLE = "available"
48
+ NOT_INSTALLED = "not_installed"
49
+ BINARY_FOUND_BUT_ERRORS = "binary_found_but_errors"
50
+ TIMEOUT = "timeout"
51
+ ERROR = "error"
52
+
53
+
44
54
  class ClaudeCliInfo(BaseModel):
45
55
  """Claude CLI information with structured data."""
46
56
 
@@ -52,8 +62,21 @@ class ClaudeCliInfo(BaseModel):
52
62
  return_code: str | None = None
53
63
 
54
64
 
65
+ class CodexCliInfo(BaseModel):
66
+ """Codex CLI information with structured data."""
67
+
68
+ status: CodexCliStatus
69
+ version: str | None = None
70
+ binary_path: str | None = None
71
+ version_output: str | None = None
72
+ error: str | None = None
73
+ return_code: str | None = None
74
+
75
+
55
76
  # Cache for Claude CLI check results
56
77
  _claude_cli_cache: tuple[float, tuple[str, dict[str, Any]]] | None = None
78
+ # Cache for Codex CLI check results
79
+ _codex_cli_cache: tuple[float, tuple[str, dict[str, Any]]] | None = None
57
80
  _cache_ttl_seconds = 300 # Cache for 5 minutes
58
81
 
59
82
 
@@ -153,6 +176,11 @@ def _get_claude_cli_path() -> str | None:
153
176
  return shutil.which("claude")
154
177
 
155
178
 
179
+ def _get_codex_cli_path() -> str | None:
180
+ """Get Codex CLI path with caching. Returns None if not found."""
181
+ return shutil.which("codex")
182
+
183
+
156
184
  async def check_claude_code() -> tuple[str, dict[str, Any]]:
157
185
  """Check Claude Code CLI installation and version by running 'claude --version'.
158
186
 
@@ -313,6 +341,162 @@ async def get_claude_cli_info() -> ClaudeCliInfo:
313
341
  )
314
342
 
315
343
 
344
+ async def check_codex_cli() -> tuple[str, dict[str, Any]]:
345
+ """Check Codex CLI installation and version by running 'codex --version'.
346
+ Results are cached for 5 minutes to avoid repeated subprocess calls.
347
+ Returns:
348
+ Tuple of (status, details) where status is 'pass'/'fail'/'warn'
349
+ Details include CLI version and binary path
350
+ """
351
+ global _codex_cli_cache
352
+ # Check if we have a valid cached result
353
+ current_time = time.time()
354
+ if _codex_cli_cache is not None:
355
+ cache_time, cached_result = _codex_cli_cache
356
+ if current_time - cache_time < _cache_ttl_seconds:
357
+ logger.debug("codex_cli_check_cache_hit")
358
+ return cached_result
359
+
360
+ logger.debug("codex_cli_check_cache_miss")
361
+
362
+ # First check if codex binary exists in PATH (cached)
363
+ codex_path = _get_codex_cli_path()
364
+ if not codex_path:
365
+ result = (
366
+ "warn",
367
+ {
368
+ "installation_status": "not_found",
369
+ "cli_status": "not_installed",
370
+ "error": "Codex CLI binary not found in PATH",
371
+ "version": None,
372
+ "binary_path": None,
373
+ },
374
+ )
375
+ # Cache the result
376
+ _codex_cli_cache = (current_time, result)
377
+ return result
378
+
379
+ try:
380
+ # Run 'codex --version' to get actual version
381
+ process = await asyncio.create_subprocess_exec(
382
+ "codex",
383
+ "--version",
384
+ stdout=asyncio.subprocess.PIPE,
385
+ stderr=asyncio.subprocess.PIPE,
386
+ )
387
+
388
+ stdout, stderr = await process.communicate()
389
+
390
+ if process.returncode == 0:
391
+ version_output = stdout.decode().strip()
392
+ # Extract version from output (e.g., "codex 0.21.0" -> "0.21.0")
393
+ if version_output:
394
+ import re
395
+
396
+ # Try to find a version pattern (e.g., "0.21.0", "v1.0.0")
397
+ version_match = re.search(
398
+ r"\b(?:v)?(\d+\.\d+(?:\.\d+)?)\b", version_output
399
+ )
400
+ if version_match:
401
+ version = version_match.group(1)
402
+ else:
403
+ # Fallback: take the last part if no version pattern found
404
+ parts = version_output.split()
405
+ version = parts[-1] if parts else "unknown"
406
+ else:
407
+ version = "unknown"
408
+
409
+ result = (
410
+ "pass",
411
+ {
412
+ "installation_status": "found",
413
+ "cli_status": "available",
414
+ "version": version,
415
+ "binary_path": codex_path,
416
+ "version_output": version_output,
417
+ },
418
+ )
419
+ # Cache the result
420
+ _codex_cli_cache = (current_time, result)
421
+ return result
422
+ else:
423
+ # Binary exists but --version failed
424
+ error_output = stderr.decode().strip() if stderr else "Unknown error"
425
+ result = (
426
+ "warn",
427
+ {
428
+ "installation_status": "found_with_issues",
429
+ "cli_status": "binary_found_but_errors",
430
+ "error": f"'codex --version' failed: {error_output}",
431
+ "version": None,
432
+ "binary_path": codex_path,
433
+ "return_code": str(process.returncode),
434
+ },
435
+ )
436
+ # Cache the result
437
+ _codex_cli_cache = (current_time, result)
438
+ return result
439
+
440
+ except TimeoutError:
441
+ result = (
442
+ "warn",
443
+ {
444
+ "installation_status": "found_with_issues",
445
+ "cli_status": "timeout",
446
+ "error": "Timeout running 'codex --version'",
447
+ "version": None,
448
+ "binary_path": codex_path,
449
+ },
450
+ )
451
+ # Cache the result
452
+ _codex_cli_cache = (current_time, result)
453
+ return result
454
+
455
+ except Exception as e:
456
+ result = (
457
+ "fail",
458
+ {
459
+ "installation_status": "error",
460
+ "cli_status": "error",
461
+ "error": f"Unexpected error running 'codex --version': {str(e)}",
462
+ "version": None,
463
+ "binary_path": codex_path,
464
+ },
465
+ )
466
+ # Cache the result
467
+ _codex_cli_cache = (current_time, result)
468
+ return result
469
+
470
+
471
+ async def get_codex_cli_info() -> CodexCliInfo:
472
+ """Get Codex CLI information as a structured Pydantic model.
473
+ Returns:
474
+ CodexCliInfo: Structured information about Codex CLI installation and status
475
+ """
476
+ cli_status, cli_details = await check_codex_cli()
477
+
478
+ # Map the status to our enum values
479
+ if cli_status == "pass":
480
+ status_value = CodexCliStatus.AVAILABLE
481
+ elif cli_details.get("cli_status") == "not_installed":
482
+ status_value = CodexCliStatus.NOT_INSTALLED
483
+ elif cli_details.get("cli_status") == "binary_found_but_errors":
484
+ status_value = CodexCliStatus.BINARY_FOUND_BUT_ERRORS
485
+ elif cli_details.get("cli_status") == "timeout":
486
+ status_value = CodexCliStatus.TIMEOUT
487
+ else:
488
+ status_value = CodexCliStatus.ERROR
489
+
490
+ return CodexCliInfo(
491
+ status=status_value,
492
+ version=cli_details.get("version"),
493
+ binary_path=cli_details.get("binary_path"),
494
+ version_output=cli_details.get("version_output"),
495
+ error=cli_details.get("error"),
496
+ return_code=cli_details.get("return_code"),
497
+ )
498
+
499
+
316
500
  async def _check_claude_sdk() -> tuple[str, dict[str, Any]]:
317
501
  """Check Claude SDK installation and version.
318
502
 
@@ -392,11 +576,17 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
392
576
  # Check OAuth credentials, CLI, and SDK separately
393
577
  oauth_status, oauth_details = await _check_oauth2_credentials()
394
578
  cli_status, cli_details = await check_claude_code()
579
+ codex_cli_status, codex_cli_details = await check_codex_cli()
395
580
  sdk_status, sdk_details = await _check_claude_sdk()
396
581
 
397
582
  # Service is ready if no check returns "fail"
398
583
  # "warn" statuses (missing credentials/CLI/SDK) don't prevent readiness
399
- if oauth_status == "fail" or cli_status == "fail" or sdk_status == "fail":
584
+ if (
585
+ oauth_status == "fail"
586
+ or cli_status == "fail"
587
+ or codex_cli_status == "fail"
588
+ or sdk_status == "fail"
589
+ ):
400
590
  response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
401
591
  failed_components = []
402
592
 
@@ -404,6 +594,8 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
404
594
  failed_components.append("oauth2_credentials")
405
595
  if cli_status == "fail":
406
596
  failed_components.append("claude_cli")
597
+ if codex_cli_status == "fail":
598
+ failed_components.append("codex_cli")
407
599
  if sdk_status == "fail":
408
600
  failed_components.append("claude_sdk")
409
601
 
@@ -424,6 +616,12 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
424
616
  "output": cli_details.get("error", "Claude CLI error"),
425
617
  }
426
618
  ],
619
+ "codex_cli": [
620
+ {
621
+ "status": codex_cli_status,
622
+ "output": codex_cli_details.get("error", "Codex CLI error"),
623
+ }
624
+ ],
427
625
  "claude_sdk": [
428
626
  {
429
627
  "status": sdk_status,
@@ -450,6 +648,12 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
450
648
  "output": f"Claude CLI: {cli_details.get('cli_status', 'unknown')}",
451
649
  }
452
650
  ],
651
+ "codex_cli": [
652
+ {
653
+ "status": codex_cli_status,
654
+ "output": f"Codex CLI: {codex_cli_details.get('cli_status', 'unknown')}",
655
+ }
656
+ ],
453
657
  "claude_sdk": [
454
658
  {
455
659
  "status": sdk_status,
@@ -479,14 +683,25 @@ async def detailed_health_check(response: Response) -> dict[str, Any]:
479
683
  # Perform all health checks
480
684
  oauth_status, oauth_details = await _check_oauth2_credentials()
481
685
  cli_status, cli_details = await check_claude_code()
686
+ codex_cli_status, codex_cli_details = await check_codex_cli()
482
687
  sdk_status, sdk_details = await _check_claude_sdk()
483
688
 
484
689
  # Determine overall status - prioritize failures, then warnings
485
690
  overall_status = "pass"
486
- if oauth_status == "fail" or cli_status == "fail" or sdk_status == "fail":
691
+ if (
692
+ oauth_status == "fail"
693
+ or cli_status == "fail"
694
+ or codex_cli_status == "fail"
695
+ or sdk_status == "fail"
696
+ ):
487
697
  overall_status = "fail"
488
698
  response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
489
- elif oauth_status == "warn" or cli_status == "warn" or sdk_status == "warn":
699
+ elif (
700
+ oauth_status == "warn"
701
+ or cli_status == "warn"
702
+ or codex_cli_status == "warn"
703
+ or sdk_status == "warn"
704
+ ):
490
705
  overall_status = "warn"
491
706
  response.status_code = status.HTTP_200_OK
492
707
 
@@ -519,6 +734,16 @@ async def detailed_health_check(response: Response) -> dict[str, Any]:
519
734
  **cli_details,
520
735
  }
521
736
  ],
737
+ "codex_cli": [
738
+ {
739
+ "componentId": "codex-cli",
740
+ "componentType": "external_dependency",
741
+ "status": codex_cli_status,
742
+ "time": current_time,
743
+ "output": f"Codex CLI: {codex_cli_details.get('cli_status', 'unknown')}",
744
+ **codex_cli_details,
745
+ }
746
+ ],
522
747
  "claude_sdk": [
523
748
  {
524
749
  "componentId": "claude-sdk",
@@ -38,9 +38,11 @@ async def create_openai_chat_completion(
38
38
  )
39
39
 
40
40
  # Handle the request using proxy service directly
41
+ # Strip the /api prefix from the path
42
+ service_path = request.url.path.removeprefix("/api")
41
43
  response = await proxy_service.handle_request(
42
44
  method=request.method,
43
- path=request.url.path,
45
+ path=service_path,
44
46
  headers=headers,
45
47
  body=body,
46
48
  query_params=query_params,
@@ -55,6 +57,8 @@ async def create_openai_chat_completion(
55
57
  # Tuple response - handle regular response
56
58
  status_code, response_headers, response_body = response
57
59
  if status_code >= 400:
60
+ # Store headers for preservation middleware
61
+ request.state.preserve_headers = response_headers
58
62
  # Forward error response directly with headers
59
63
  return ProxyResponse(
60
64
  content=response_body,
@@ -128,9 +132,11 @@ async def create_anthropic_message(
128
132
  )
129
133
 
130
134
  # Handle the request using proxy service directly
135
+ # Strip the /api prefix from the path
136
+ service_path = request.url.path.removeprefix("/api")
131
137
  response = await proxy_service.handle_request(
132
138
  method=request.method,
133
- path=request.url.path,
139
+ path=service_path,
134
140
  headers=headers,
135
141
  body=body,
136
142
  query_params=query_params,
@@ -145,6 +151,8 @@ async def create_anthropic_message(
145
151
  # Tuple response - handle regular response
146
152
  status_code, response_headers, response_body = response
147
153
  if status_code >= 400:
154
+ # Store headers for preservation middleware
155
+ request.state.preserve_headers = response_headers
148
156
  # Forward error response directly with headers
149
157
  return ProxyResponse(
150
158
  content=response_body,
@@ -163,15 +171,26 @@ async def create_anthropic_message(
163
171
  if line.strip():
164
172
  yield f"{line}\n".encode()
165
173
 
174
+ # Start with the response headers from proxy service
175
+ streaming_headers = response_headers.copy()
176
+
177
+ # Ensure critical headers for streaming
178
+ streaming_headers["Cache-Control"] = "no-cache"
179
+ streaming_headers["Connection"] = "keep-alive"
180
+
181
+ # Set content-type if not already set by upstream
182
+ if "content-type" not in streaming_headers:
183
+ streaming_headers["content-type"] = "text/event-stream"
184
+
166
185
  return StreamingResponse(
167
186
  stream_generator(),
168
187
  media_type="text/event-stream",
169
- headers={
170
- "Cache-Control": "no-cache",
171
- "Connection": "keep-alive",
172
- },
188
+ headers=streaming_headers,
173
189
  )
174
190
  else:
191
+ # Store headers for preservation middleware
192
+ request.state.preserve_headers = response_headers
193
+
175
194
  # Parse JSON response
176
195
  response_data = json.loads(response_body.decode())
177
196
 
@@ -35,7 +35,7 @@ class PermissionService:
35
35
  async def start(self) -> None:
36
36
  if self._expiry_task is None:
37
37
  self._expiry_task = asyncio.create_task(self._expiry_checker())
38
- logger.info("permission_service_started")
38
+ logger.debug("permission_service_started")
39
39
 
40
40
  async def stop(self) -> None:
41
41
  self._shutdown = True
@@ -44,7 +44,7 @@ class PermissionService:
44
44
  with contextlib.suppress(asyncio.CancelledError):
45
45
  await self._expiry_task
46
46
  self._expiry_task = None
47
- logger.info("permission_service_stopped")
47
+ logger.debug("permission_service_stopped")
48
48
 
49
49
  async def request_permission(self, tool_name: str, input: dict[str, str]) -> str:
50
50
  """Create a new permission request.
@@ -0,0 +1,13 @@
1
+ """OpenAI authentication components for Codex integration."""
2
+
3
+ from .credentials import OpenAICredentials, OpenAITokenManager
4
+ from .oauth_client import OpenAIOAuthClient
5
+ from .storage import OpenAITokenStorage
6
+
7
+
8
+ __all__ = [
9
+ "OpenAICredentials",
10
+ "OpenAITokenManager",
11
+ "OpenAIOAuthClient",
12
+ "OpenAITokenStorage",
13
+ ]
@@ -0,0 +1,166 @@
1
+ """OpenAI credentials management for Codex authentication."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ import jwt
7
+ import structlog
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+ from .storage import OpenAITokenStorage
11
+
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ class OpenAICredentials(BaseModel):
17
+ """OpenAI authentication credentials model."""
18
+
19
+ access_token: str = Field(..., description="OpenAI access token (JWT)")
20
+ refresh_token: str = Field(..., description="OpenAI refresh token")
21
+ expires_at: datetime = Field(..., description="Token expiration timestamp")
22
+ account_id: str = Field(..., description="OpenAI account ID extracted from token")
23
+ active: bool = Field(default=True, description="Whether credentials are active")
24
+
25
+ @field_validator("expires_at", mode="before")
26
+ @classmethod
27
+ def parse_expires_at(cls, v: Any) -> datetime:
28
+ """Parse expiration timestamp."""
29
+ if isinstance(v, datetime):
30
+ # Ensure timezone-aware datetime
31
+ if v.tzinfo is None:
32
+ return v.replace(tzinfo=UTC)
33
+ return v
34
+
35
+ if isinstance(v, str):
36
+ # Handle ISO format strings
37
+ try:
38
+ dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
39
+ if dt.tzinfo is None:
40
+ dt = dt.replace(tzinfo=UTC)
41
+ return dt
42
+ except ValueError as e:
43
+ raise ValueError(f"Invalid datetime format: {v}") from e
44
+
45
+ if isinstance(v, int | float):
46
+ # Handle Unix timestamps
47
+ return datetime.fromtimestamp(v, tz=UTC)
48
+
49
+ raise ValueError(f"Cannot parse datetime from {type(v)}: {v}")
50
+
51
+ @field_validator("account_id", mode="before")
52
+ @classmethod
53
+ def extract_account_id(cls, v: Any, info: Any) -> str:
54
+ """Extract account ID from access token if not provided."""
55
+ if isinstance(v, str) and v:
56
+ return v
57
+
58
+ # Try to extract from access_token
59
+ access_token = None
60
+ if hasattr(info, "data") and info.data and isinstance(info.data, dict):
61
+ access_token = info.data.get("access_token")
62
+
63
+ if access_token and isinstance(access_token, str):
64
+ try:
65
+ # Decode JWT without verification to extract claims
66
+ decoded = jwt.decode(access_token, options={"verify_signature": False})
67
+ if "org_id" in decoded and isinstance(decoded["org_id"], str):
68
+ return decoded["org_id"]
69
+ elif "sub" in decoded and isinstance(decoded["sub"], str):
70
+ return decoded["sub"]
71
+ elif "account_id" in decoded and isinstance(decoded["account_id"], str):
72
+ return decoded["account_id"]
73
+ except Exception as e:
74
+ logger.warning("Failed to extract account_id from token", error=str(e))
75
+
76
+ raise ValueError(
77
+ "account_id is required and could not be extracted from access_token"
78
+ )
79
+
80
+ def is_expired(self) -> bool:
81
+ """Check if the access token is expired."""
82
+ now = datetime.now(UTC)
83
+ return now >= self.expires_at
84
+
85
+ def expires_in_seconds(self) -> int:
86
+ """Get seconds until token expires."""
87
+ now = datetime.now(UTC)
88
+ delta = self.expires_at - now
89
+ return max(0, int(delta.total_seconds()))
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ """Convert to dictionary for storage."""
93
+ return {
94
+ "access_token": self.access_token,
95
+ "refresh_token": self.refresh_token,
96
+ "expires_at": self.expires_at.isoformat(),
97
+ "account_id": self.account_id,
98
+ "active": self.active,
99
+ }
100
+
101
+ @classmethod
102
+ def from_dict(cls, data: dict[str, Any]) -> "OpenAICredentials":
103
+ """Create from dictionary."""
104
+ return cls(**data)
105
+
106
+
107
+ class OpenAITokenManager:
108
+ """Manages OpenAI token storage and refresh operations."""
109
+
110
+ def __init__(self, storage: OpenAITokenStorage | None = None):
111
+ """Initialize token manager.
112
+
113
+ Args:
114
+ storage: Token storage backend. If None, uses default TOML file storage.
115
+ """
116
+ self.storage = storage or OpenAITokenStorage()
117
+
118
+ async def load_credentials(self) -> OpenAICredentials | None:
119
+ """Load credentials from storage."""
120
+ try:
121
+ return await self.storage.load()
122
+ except Exception as e:
123
+ logger.error("Failed to load OpenAI credentials", error=str(e))
124
+ return None
125
+
126
+ async def save_credentials(self, credentials: OpenAICredentials) -> bool:
127
+ """Save credentials to storage."""
128
+ try:
129
+ return await self.storage.save(credentials)
130
+ except Exception as e:
131
+ logger.error("Failed to save OpenAI credentials", error=str(e))
132
+ return False
133
+
134
+ async def delete_credentials(self) -> bool:
135
+ """Delete credentials from storage."""
136
+ try:
137
+ return await self.storage.delete()
138
+ except Exception as e:
139
+ logger.error("Failed to delete OpenAI credentials", error=str(e))
140
+ return False
141
+
142
+ async def has_credentials(self) -> bool:
143
+ """Check if credentials exist."""
144
+ try:
145
+ return await self.storage.exists()
146
+ except Exception:
147
+ return False
148
+
149
+ async def get_valid_token(self) -> str | None:
150
+ """Get a valid access token, refreshing if necessary."""
151
+ credentials = await self.load_credentials()
152
+ if not credentials or not credentials.active:
153
+ return None
154
+
155
+ # If token is not expired, return it
156
+ if not credentials.is_expired():
157
+ return credentials.access_token
158
+
159
+ # TODO: Implement token refresh logic
160
+ # For now, return None if expired (user needs to re-authenticate)
161
+ logger.warning("OpenAI token expired, refresh not yet implemented")
162
+ return None
163
+
164
+ def get_storage_location(self) -> str:
165
+ """Get storage location description."""
166
+ return self.storage.get_location()