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.
Files changed (42) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/codex/__init__.py +11 -0
  3. ccproxy/adapters/openai/models.py +1 -1
  4. ccproxy/adapters/openai/response_adapter.py +355 -0
  5. ccproxy/adapters/openai/response_models.py +178 -0
  6. ccproxy/api/app.py +31 -3
  7. ccproxy/api/dependencies.py +1 -8
  8. ccproxy/api/middleware/errors.py +15 -7
  9. ccproxy/api/routes/codex.py +1251 -0
  10. ccproxy/api/routes/health.py +228 -3
  11. ccproxy/auth/openai/__init__.py +13 -0
  12. ccproxy/auth/openai/credentials.py +166 -0
  13. ccproxy/auth/openai/oauth_client.py +334 -0
  14. ccproxy/auth/openai/storage.py +184 -0
  15. ccproxy/claude_sdk/options.py +1 -1
  16. ccproxy/cli/commands/auth.py +398 -1
  17. ccproxy/cli/commands/serve.py +3 -1
  18. ccproxy/config/claude.py +1 -1
  19. ccproxy/config/codex.py +100 -0
  20. ccproxy/config/scheduler.py +8 -8
  21. ccproxy/config/settings.py +19 -0
  22. ccproxy/core/codex_transformers.py +389 -0
  23. ccproxy/core/http_transformers.py +153 -2
  24. ccproxy/data/claude_headers_fallback.json +37 -0
  25. ccproxy/data/codex_headers_fallback.json +14 -0
  26. ccproxy/models/detection.py +82 -0
  27. ccproxy/models/requests.py +22 -0
  28. ccproxy/models/responses.py +16 -0
  29. ccproxy/scheduler/manager.py +2 -2
  30. ccproxy/scheduler/tasks.py +105 -65
  31. ccproxy/services/claude_detection_service.py +7 -33
  32. ccproxy/services/codex_detection_service.py +252 -0
  33. ccproxy/services/proxy_service.py +530 -0
  34. ccproxy/utils/model_mapping.py +7 -5
  35. ccproxy/utils/startup_helpers.py +205 -12
  36. ccproxy/utils/version_checker.py +6 -0
  37. ccproxy_api-0.1.7.dist-info/METADATA +615 -0
  38. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/RECORD +41 -28
  39. ccproxy_api-0.1.5.dist-info/METADATA +0 -396
  40. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/WHEEL +0 -0
  41. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.dist-info}/entry_points.txt +0 -0
  42. {ccproxy_api-0.1.5.dist-info → ccproxy_api-0.1.7.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",
@@ -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()