ccproxy-api 0.1.5__py3-none-any.whl → 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/codex/__init__.py +11 -0
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/api/app.py +31 -3
- ccproxy/api/dependencies.py +1 -8
- ccproxy/api/middleware/errors.py +15 -7
- ccproxy/api/routes/codex.py +1251 -0
- ccproxy/api/routes/health.py +228 -3
- ccproxy/auth/openai/__init__.py +13 -0
- ccproxy/auth/openai/credentials.py +166 -0
- ccproxy/auth/openai/oauth_client.py +334 -0
- ccproxy/auth/openai/storage.py +184 -0
- ccproxy/claude_sdk/options.py +1 -1
- ccproxy/cli/commands/auth.py +398 -1
- ccproxy/cli/commands/serve.py +3 -1
- ccproxy/config/claude.py +1 -1
- ccproxy/config/codex.py +100 -0
- ccproxy/config/scheduler.py +8 -8
- ccproxy/config/settings.py +19 -0
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +153 -2
- ccproxy/data/claude_headers_fallback.json +37 -0
- ccproxy/data/codex_headers_fallback.json +14 -0
- ccproxy/models/detection.py +82 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -0
- ccproxy/scheduler/manager.py +2 -2
- ccproxy/scheduler/tasks.py +105 -65
- ccproxy/services/claude_detection_service.py +7 -33
- ccproxy/services/codex_detection_service.py +252 -0
- ccproxy/services/proxy_service.py +530 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +205 -12
- ccproxy/utils/version_checker.py +6 -0
- ccproxy_api-0.1.7.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/RECORD +41 -28
- ccproxy_api-0.1.5.dist-info/METADATA +0 -396
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/licenses/LICENSE +0 -0
ccproxy/api/routes/health.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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",
|
|
@@ -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()
|