ccproxy-api 0.1.2__py3-none-any.whl → 0.1.3__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 (108) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +62 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +76 -29
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -2
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +30 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +5 -16
  45. ccproxy/cli/options/claude_options.py +19 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +13 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +29 -2
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +220 -328
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. ccproxy/cli/commands/permission.py +0 -128
  104. ccproxy_api-0.1.2.dist-info/RECORD +0 -150
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +0 -0
  108. {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,297 @@
1
+ """Request content logging middleware for capturing full HTTP request/response data."""
2
+
3
+ import json
4
+ from collections.abc import AsyncGenerator
5
+ from typing import Any
6
+
7
+ import structlog
8
+ from fastapi import Request, Response
9
+ from fastapi.responses import StreamingResponse
10
+ from starlette.middleware.base import BaseHTTPMiddleware
11
+ from starlette.types import ASGIApp
12
+
13
+ from ccproxy.utils.simple_request_logger import (
14
+ append_streaming_log,
15
+ write_request_log,
16
+ )
17
+
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ class RequestContentLoggingMiddleware(BaseHTTPMiddleware):
23
+ """Middleware for logging full HTTP request and response content."""
24
+
25
+ def __init__(self, app: ASGIApp):
26
+ """Initialize the request content logging middleware.
27
+
28
+ Args:
29
+ app: The ASGI application
30
+ """
31
+ super().__init__(app)
32
+
33
+ async def dispatch(self, request: Request, call_next: Any) -> Any:
34
+ """Process the request and log content.
35
+
36
+ Args:
37
+ request: The incoming HTTP request
38
+ call_next: The next middleware/handler in the chain
39
+
40
+ Returns:
41
+ The HTTP response
42
+ """
43
+ # Get request ID and timestamp from context if available
44
+ request_id = self._get_request_id(request)
45
+ timestamp = self._get_timestamp_prefix(request)
46
+
47
+ # Log incoming request
48
+ await self._log_request(request, request_id, timestamp)
49
+
50
+ # Process the request
51
+ response = await call_next(request)
52
+
53
+ # Log outgoing response
54
+ await self._log_response(response, request_id, timestamp)
55
+
56
+ return response
57
+
58
+ def _get_request_id(self, request: Request) -> str:
59
+ """Extract request ID from request state or context.
60
+
61
+ Args:
62
+ request: The HTTP request
63
+
64
+ Returns:
65
+ Request ID string or 'unknown' if not found
66
+ """
67
+ try:
68
+ # Try to get from request state
69
+ if hasattr(request.state, "request_id"):
70
+ return str(request.state.request_id)
71
+
72
+ # Try to get from request context
73
+ if hasattr(request.state, "context"):
74
+ context = request.state.context
75
+ if hasattr(context, "request_id"):
76
+ return str(context.request_id)
77
+
78
+ # Fallback to UUID if available in headers
79
+ if "x-request-id" in request.headers:
80
+ return request.headers["x-request-id"]
81
+
82
+ except Exception:
83
+ pass # Ignore errors and use fallback
84
+
85
+ return "unknown"
86
+
87
+ def _get_timestamp_prefix(self, request: Request) -> str | None:
88
+ """Extract timestamp prefix from request context.
89
+
90
+ Args:
91
+ request: The HTTP request
92
+
93
+ Returns:
94
+ Timestamp prefix string or None if not found
95
+ """
96
+ try:
97
+ # Try to get from request context
98
+ if hasattr(request.state, "context"):
99
+ context = request.state.context
100
+ if hasattr(context, "get_log_timestamp_prefix"):
101
+ result = context.get_log_timestamp_prefix()
102
+ return str(result) if result is not None else None
103
+ except Exception:
104
+ pass # Ignore errors and use fallback
105
+
106
+ return None
107
+
108
+ async def _log_request(
109
+ self, request: Request, request_id: str, timestamp: str | None
110
+ ) -> None:
111
+ """Log incoming HTTP request content.
112
+
113
+ Args:
114
+ request: The HTTP request
115
+ request_id: Request identifier
116
+ timestamp: Timestamp prefix for the log file
117
+ """
118
+ try:
119
+ # Read request body
120
+ body = await request.body()
121
+
122
+ # Create request log data
123
+ request_data = {
124
+ "method": request.method,
125
+ "url": str(request.url),
126
+ "headers": dict(request.headers),
127
+ "query_params": dict(request.query_params),
128
+ "path_params": dict(request.path_params)
129
+ if hasattr(request, "path_params")
130
+ else {},
131
+ "body_size": len(body) if body else 0,
132
+ "body": None,
133
+ }
134
+
135
+ # Try to parse body as JSON, fallback to string
136
+ if body:
137
+ try:
138
+ request_data["body"] = json.loads(body.decode("utf-8"))
139
+ except (json.JSONDecodeError, UnicodeDecodeError):
140
+ try:
141
+ request_data["body"] = body.decode("utf-8", errors="replace")
142
+ except Exception:
143
+ request_data["body"] = f"<binary data of length {len(body)}>"
144
+
145
+ await write_request_log(
146
+ request_id=request_id,
147
+ log_type="middleware_request",
148
+ data=request_data,
149
+ timestamp=timestamp,
150
+ )
151
+
152
+ except Exception as e:
153
+ logger.error(
154
+ "failed_to_log_request_content",
155
+ request_id=request_id,
156
+ error=str(e),
157
+ )
158
+
159
+ async def _log_response(
160
+ self, response: Response, request_id: str, timestamp: str | None
161
+ ) -> None:
162
+ """Log outgoing HTTP response content.
163
+
164
+ Args:
165
+ response: The HTTP response
166
+ request_id: Request identifier
167
+ timestamp: Timestamp prefix for the log file
168
+ """
169
+ try:
170
+ if isinstance(response, StreamingResponse):
171
+ # Handle streaming response
172
+ await self._log_streaming_response(response, request_id, timestamp)
173
+ else:
174
+ # Handle regular response
175
+ await self._log_regular_response(response, request_id, timestamp)
176
+
177
+ except Exception as e:
178
+ logger.error(
179
+ "failed_to_log_response_content",
180
+ request_id=request_id,
181
+ error=str(e),
182
+ )
183
+
184
+ async def _log_regular_response(
185
+ self, response: Response, request_id: str, timestamp: str | None
186
+ ) -> None:
187
+ """Log regular (non-streaming) HTTP response.
188
+
189
+ Args:
190
+ response: The HTTP response
191
+ request_id: Request identifier
192
+ timestamp: Timestamp prefix for the log file
193
+ """
194
+ # Create response log data
195
+ response_data = {
196
+ "status_code": response.status_code,
197
+ "headers": dict(response.headers),
198
+ "body": None,
199
+ }
200
+
201
+ # Try to get response body
202
+ if hasattr(response, "body") and response.body:
203
+ body = response.body
204
+ response_data["body_size"] = len(body)
205
+
206
+ try:
207
+ # Convert to bytes if needed
208
+ body_bytes = bytes(body) if isinstance(body, memoryview) else body
209
+ # Try to parse as JSON
210
+ response_data["body"] = json.loads(body_bytes.decode("utf-8"))
211
+ except (json.JSONDecodeError, UnicodeDecodeError):
212
+ try:
213
+ # Fallback to string
214
+ body_bytes = bytes(body) if isinstance(body, memoryview) else body
215
+ response_data["body"] = body_bytes.decode("utf-8", errors="replace")
216
+ except Exception:
217
+ response_data["body"] = f"<binary data of length {len(body)}>"
218
+ else:
219
+ response_data["body_size"] = 0
220
+
221
+ await write_request_log(
222
+ request_id=request_id,
223
+ log_type="middleware_response",
224
+ data=response_data,
225
+ timestamp=timestamp,
226
+ )
227
+
228
+ async def _log_streaming_response(
229
+ self, response: StreamingResponse, request_id: str, timestamp: str | None
230
+ ) -> None:
231
+ """Log streaming HTTP response by wrapping the stream.
232
+
233
+ Args:
234
+ response: The streaming HTTP response
235
+ request_id: Request identifier
236
+ timestamp: Timestamp prefix for the log file
237
+ """
238
+ # Log response metadata first
239
+ response_data = {
240
+ "status_code": response.status_code,
241
+ "headers": dict(response.headers),
242
+ "body_type": "streaming",
243
+ "media_type": response.media_type,
244
+ }
245
+
246
+ await write_request_log(
247
+ request_id=request_id,
248
+ log_type="middleware_response",
249
+ data=response_data,
250
+ timestamp=timestamp,
251
+ )
252
+
253
+ # Wrap the streaming response to capture content
254
+ original_body_iterator = response.body_iterator
255
+
256
+ def create_logged_body_iterator() -> AsyncGenerator[
257
+ str | bytes | memoryview[int], None
258
+ ]:
259
+ """Create wrapper around the original body iterator to log streaming content."""
260
+
261
+ async def logged_body_iterator() -> AsyncGenerator[
262
+ str | bytes | memoryview[int], None
263
+ ]:
264
+ try:
265
+ async for chunk in original_body_iterator:
266
+ # Log chunk as raw data
267
+ if isinstance(chunk, bytes | bytearray):
268
+ await append_streaming_log(
269
+ request_id=request_id,
270
+ log_type="middleware_streaming",
271
+ data=bytes(chunk),
272
+ timestamp=timestamp,
273
+ )
274
+ elif isinstance(chunk, str):
275
+ await append_streaming_log(
276
+ request_id=request_id,
277
+ log_type="middleware_streaming",
278
+ data=chunk.encode("utf-8"),
279
+ timestamp=timestamp,
280
+ )
281
+
282
+ yield chunk
283
+
284
+ except Exception as e:
285
+ logger.error(
286
+ "error_in_streaming_response_logging",
287
+ request_id=request_id,
288
+ error=str(e),
289
+ )
290
+ # Continue with original iterator if logging fails
291
+ async for chunk in original_body_iterator:
292
+ yield chunk
293
+
294
+ return logged_body_iterator()
295
+
296
+ # Replace the body iterator with our logged version
297
+ response.body_iterator = create_logged_body_iterator()
@@ -1,6 +1,7 @@
1
1
  """Request ID middleware for generating and tracking request IDs."""
2
2
 
3
3
  import uuid
4
+ from datetime import UTC, datetime
4
5
  from typing import Any
5
6
 
6
7
  import structlog
@@ -38,6 +39,9 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
38
39
  # Generate or extract request ID
39
40
  request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
40
41
 
42
+ # Generate datetime for consistent logging across all layers
43
+ log_timestamp = datetime.now(UTC)
44
+
41
45
  # Get DuckDB storage from app state if available
42
46
  storage = getattr(request.app.state, "duckdb_storage", None)
43
47
 
@@ -45,6 +49,7 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
45
49
  async with request_context(
46
50
  request_id=request_id,
47
51
  storage=storage,
52
+ log_timestamp=log_timestamp,
48
53
  method=request.method,
49
54
  path=str(request.url.path),
50
55
  client_ip=request.client.host if request.client else "unknown",
@@ -1,9 +1,5 @@
1
1
  """Server header middleware to set a default server header for non-proxy routes."""
2
2
 
3
- from typing import Any
4
-
5
- from fastapi import Request, Response
6
- from starlette.middleware.base import BaseHTTPMiddleware
7
3
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
8
4
 
9
5
 
@@ -3,7 +3,13 @@
3
3
  # from ccproxy.api.routes.auth import router as auth_router # Module doesn't exist
4
4
  from ccproxy.api.routes.claude import router as claude_router
5
5
  from ccproxy.api.routes.health import router as health_router
6
- from ccproxy.api.routes.metrics import router as metrics_router
6
+ from ccproxy.api.routes.metrics import (
7
+ dashboard_router,
8
+ logs_router,
9
+ )
10
+ from ccproxy.api.routes.metrics import (
11
+ prometheus_router as metrics_router,
12
+ )
7
13
  from ccproxy.api.routes.proxy import router as proxy_router
8
14
 
9
15
 
@@ -12,5 +18,7 @@ __all__ = [
12
18
  "claude_router",
13
19
  "health_router",
14
20
  "metrics_router",
21
+ "logs_router",
22
+ "dashboard_router",
15
23
  "proxy_router",
16
24
  ]
@@ -2,10 +2,9 @@
2
2
 
3
3
  import json
4
4
  from collections.abc import AsyncIterator
5
- from typing import Any
6
5
 
7
6
  import structlog
8
- from fastapi import APIRouter, Depends, HTTPException, Request
7
+ from fastapi import APIRouter, HTTPException, Request
9
8
  from fastapi.responses import StreamingResponse
10
9
 
11
10
  from ccproxy.adapters.openai.adapter import (
@@ -13,9 +12,8 @@ from ccproxy.adapters.openai.adapter import (
13
12
  OpenAIChatCompletionRequest,
14
13
  OpenAIChatCompletionResponse,
15
14
  )
16
- from ccproxy.api.dependencies import get_claude_service
15
+ from ccproxy.api.dependencies import ClaudeServiceDep
17
16
  from ccproxy.models.messages import MessageCreateParams, MessageResponse
18
- from ccproxy.services.claude_sdk_service import ClaudeSDKService
19
17
 
20
18
 
21
19
  # Create the router for Claude SDK endpoints
@@ -28,7 +26,7 @@ logger = structlog.get_logger(__name__)
28
26
  async def create_openai_chat_completion(
29
27
  request: Request,
30
28
  openai_request: OpenAIChatCompletionRequest,
31
- claude_service: ClaudeSDKService = Depends(get_claude_service),
29
+ claude_service: ClaudeServiceDep,
32
30
  ) -> StreamingResponse | OpenAIChatCompletionResponse:
33
31
  """Create a chat completion using Claude SDK with OpenAI-compatible format.
34
32
 
@@ -80,7 +78,13 @@ async def create_openai_chat_completion(
80
78
  )
81
79
  else:
82
80
  # Convert non-streaming response to OpenAI format using adapter
83
- openai_response = adapter.adapt_response(response) # type: ignore[arg-type]
81
+ # Convert MessageResponse model to dict for adapter
82
+ # In non-streaming mode, response should always be MessageResponse
83
+ assert isinstance(response, MessageResponse), (
84
+ "Non-streaming response must be MessageResponse"
85
+ )
86
+ response_dict = response.model_dump()
87
+ openai_response = adapter.adapt_response(response_dict)
84
88
  return OpenAIChatCompletionResponse.model_validate(openai_response)
85
89
 
86
90
  except Exception as e:
@@ -97,7 +101,7 @@ async def create_openai_chat_completion(
97
101
  @router.post("/v1/messages", response_model=None)
98
102
  async def create_anthropic_message(
99
103
  request: MessageCreateParams,
100
- claude_service: ClaudeSDKService = Depends(get_claude_service),
104
+ claude_service: ClaudeServiceDep,
101
105
  ) -> StreamingResponse | MessageResponse:
102
106
  """Create a message using Claude SDK with Anthropic format.
103
107
 
@@ -127,9 +131,17 @@ async def create_anthropic_message(
127
131
  async def anthropic_stream_generator() -> AsyncIterator[bytes]:
128
132
  async for chunk in response: # type: ignore[union-attr]
129
133
  if chunk:
130
- yield f"data: {json.dumps(chunk)}\n\n".encode()
131
- # Send final chunk
132
- yield b"data: [DONE]\n\n"
134
+ # All chunks from Claude SDK streaming should be dict format
135
+ # and need proper SSE event formatting
136
+ if isinstance(chunk, dict):
137
+ # Determine event type from chunk type
138
+ event_type = chunk.get("type", "message_delta")
139
+ yield f"event: {event_type}\n".encode()
140
+ yield f"data: {json.dumps(chunk)}\n\n".encode()
141
+ else:
142
+ # Fallback for unexpected format
143
+ yield f"data: {json.dumps(chunk)}\n\n".encode()
144
+ # No final [DONE] chunk for Anthropic format
133
145
 
134
146
  return StreamingResponse(
135
147
  anthropic_stream_generator(),
@@ -148,28 +160,7 @@ async def create_anthropic_message(
148
160
  from ccproxy.core.errors import ClaudeProxyError
149
161
 
150
162
  if isinstance(e, ClaudeProxyError):
151
- raise
152
- raise HTTPException(
153
- status_code=500, detail=f"Internal server error: {str(e)}"
154
- ) from e
155
-
156
-
157
- @router.get("/v1/models", response_model=None)
158
- async def list_models(
159
- claude_service: ClaudeSDKService = Depends(get_claude_service),
160
- ) -> dict[str, Any]:
161
- """List available models using Claude SDK service.
162
-
163
- Returns a combined list of Anthropic models and recent OpenAI models.
164
- """
165
- try:
166
- return await claude_service.list_models()
167
- except Exception as e:
168
- # Re-raise specific proxy errors to be handled by the error handler
169
- from ccproxy.core.errors import ClaudeProxyError
170
-
171
- if isinstance(e, ClaudeProxyError):
172
- raise
163
+ raise e
173
164
  raise HTTPException(
174
165
  status_code=500, detail=f"Internal server error: {str(e)}"
175
166
  ) from e
@@ -13,10 +13,12 @@ import asyncio
13
13
  import functools
14
14
  import shutil
15
15
  import time
16
- from datetime import UTC, datetime, timezone
16
+ from datetime import UTC, datetime
17
+ from enum import Enum
17
18
  from typing import Any
18
19
 
19
20
  from fastapi import APIRouter, Response, status
21
+ from pydantic import BaseModel
20
22
  from structlog import get_logger
21
23
 
22
24
  from ccproxy import __version__
@@ -28,6 +30,28 @@ from ccproxy.services.credentials import CredentialsManager
28
30
  router = APIRouter()
29
31
  logger = get_logger(__name__)
30
32
 
33
+
34
+ class ClaudeCliStatus(str, Enum):
35
+ """Claude CLI status enumeration."""
36
+
37
+ AVAILABLE = "available"
38
+ NOT_INSTALLED = "not_installed"
39
+ BINARY_FOUND_BUT_ERRORS = "binary_found_but_errors"
40
+ TIMEOUT = "timeout"
41
+ ERROR = "error"
42
+
43
+
44
+ class ClaudeCliInfo(BaseModel):
45
+ """Claude CLI information with structured data."""
46
+
47
+ status: ClaudeCliStatus
48
+ version: str | None = None
49
+ binary_path: str | None = None
50
+ version_output: str | None = None
51
+ error: str | None = None
52
+ return_code: str | None = None
53
+
54
+
31
55
  # Cache for Claude CLI check results
32
56
  _claude_cli_cache: tuple[float, tuple[str, dict[str, Any]]] | None = None
33
57
  _cache_ttl_seconds = 300 # Cache for 5 minutes
@@ -129,7 +153,7 @@ def _get_claude_cli_path() -> str | None:
129
153
  return shutil.which("claude")
130
154
 
131
155
 
132
- async def _check_claude_code() -> tuple[str, dict[str, Any]]:
156
+ async def check_claude_code() -> tuple[str, dict[str, Any]]:
133
157
  """Check Claude Code CLI installation and version by running 'claude --version'.
134
158
 
135
159
  Results are cached for 5 minutes to avoid repeated subprocess calls.
@@ -259,6 +283,36 @@ async def _check_claude_code() -> tuple[str, dict[str, Any]]:
259
283
  return result
260
284
 
261
285
 
286
+ async def get_claude_cli_info() -> ClaudeCliInfo:
287
+ """Get Claude CLI information as a structured Pydantic model.
288
+
289
+ Returns:
290
+ ClaudeCliInfo: Structured information about Claude CLI installation and status
291
+ """
292
+ cli_status, cli_details = await check_claude_code()
293
+
294
+ # Map the status to our enum values
295
+ if cli_status == "pass":
296
+ status_value = ClaudeCliStatus.AVAILABLE
297
+ elif cli_details.get("cli_status") == "not_installed":
298
+ status_value = ClaudeCliStatus.NOT_INSTALLED
299
+ elif cli_details.get("cli_status") == "binary_found_but_errors":
300
+ status_value = ClaudeCliStatus.BINARY_FOUND_BUT_ERRORS
301
+ elif cli_details.get("cli_status") == "timeout":
302
+ status_value = ClaudeCliStatus.TIMEOUT
303
+ else:
304
+ status_value = ClaudeCliStatus.ERROR
305
+
306
+ return ClaudeCliInfo(
307
+ status=status_value,
308
+ version=cli_details.get("version"),
309
+ binary_path=cli_details.get("binary_path"),
310
+ version_output=cli_details.get("version_output"),
311
+ error=cli_details.get("error"),
312
+ return_code=cli_details.get("return_code"),
313
+ )
314
+
315
+
262
316
  async def _check_claude_sdk() -> tuple[str, dict[str, Any]]:
263
317
  """Check Claude SDK installation and version.
264
318
 
@@ -337,7 +391,7 @@ async def readiness_probe(response: Response) -> dict[str, Any]:
337
391
 
338
392
  # Check OAuth credentials, CLI, and SDK separately
339
393
  oauth_status, oauth_details = await _check_oauth2_credentials()
340
- cli_status, cli_details = await _check_claude_code()
394
+ cli_status, cli_details = await check_claude_code()
341
395
  sdk_status, sdk_details = await _check_claude_sdk()
342
396
 
343
397
  # Service is ready if no check returns "fail"
@@ -424,7 +478,7 @@ async def detailed_health_check(response: Response) -> dict[str, Any]:
424
478
 
425
479
  # Perform all health checks
426
480
  oauth_status, oauth_details = await _check_oauth2_credentials()
427
- cli_status, cli_details = await _check_claude_code()
481
+ cli_status, cli_details = await check_claude_code()
428
482
  sdk_status, sdk_details = await _check_claude_sdk()
429
483
 
430
484
  # Determine overall status - prioritize failures, then warnings