cli2api 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.
@@ -0,0 +1,255 @@
1
+ """Responses endpoint - OpenAI Responses API compatible.
2
+
3
+ This is the newer OpenAI API format that some clients use.
4
+ We translate it to our internal format and use the same provider.
5
+ """
6
+
7
+ import time
8
+ import uuid
9
+ from typing import Any, AsyncIterator, Optional
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException
12
+ from fastapi.responses import StreamingResponse
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from cli2api.api.dependencies import get_provider
16
+ from cli2api.api.utils import parse_model_name
17
+ from cli2api.providers.claude import ClaudeCodeProvider
18
+ from cli2api.schemas.openai import ChatMessage
19
+ from cli2api.streaming.sse import sse_encode, sse_error
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ # === Request Models for Responses API ===
25
+
26
+
27
+ class ResponsesInputMessage(BaseModel):
28
+ """Input message for Responses API."""
29
+
30
+ model_config = ConfigDict(extra="ignore")
31
+
32
+ role: str
33
+ content: Any # Can be string or list of content blocks
34
+
35
+
36
+ class ResponsesRequest(BaseModel):
37
+ """Request body for /v1/responses."""
38
+
39
+ model_config = ConfigDict(extra="ignore")
40
+
41
+ model: str
42
+ input: list[ResponsesInputMessage] | str
43
+ stream: bool = False
44
+ instructions: Optional[str] = None
45
+ temperature: Optional[float] = None
46
+ max_output_tokens: Optional[int] = None
47
+ # Additional fields
48
+ tools: Optional[list[Any]] = None
49
+ tool_choice: Optional[Any] = None
50
+ metadata: Optional[dict] = None
51
+
52
+
53
+ # === Response Models ===
54
+
55
+
56
+ class ResponsesOutput(BaseModel):
57
+ """Output content in response."""
58
+
59
+ type: str = "message"
60
+ id: str
61
+ role: str = "assistant"
62
+ content: list[dict]
63
+
64
+
65
+ class ResponsesResponse(BaseModel):
66
+ """Response body for /v1/responses."""
67
+
68
+ id: str
69
+ object: str = "response"
70
+ created_at: int
71
+ model: str
72
+ output: list[ResponsesOutput]
73
+ usage: Optional[dict] = None
74
+
75
+
76
+ def convert_to_chat_messages(request: ResponsesRequest) -> list[ChatMessage]:
77
+ """Convert Responses API input to ChatMessage list."""
78
+ messages = []
79
+
80
+ # Add instructions as system message
81
+ if request.instructions:
82
+ messages.append(ChatMessage(role="system", content=request.instructions))
83
+
84
+ # Handle input
85
+ if isinstance(request.input, str):
86
+ messages.append(ChatMessage(role="user", content=request.input))
87
+ else:
88
+ for msg in request.input:
89
+ content = msg.content
90
+ if isinstance(content, list):
91
+ # Extract text from content blocks
92
+ texts = []
93
+ for item in content:
94
+ if isinstance(item, dict) and item.get("type") == "text":
95
+ texts.append(item.get("text", ""))
96
+ elif isinstance(item, str):
97
+ texts.append(item)
98
+ content = "\n".join(texts)
99
+ elif content is None:
100
+ content = ""
101
+
102
+ role = msg.role
103
+ if role not in ("system", "user", "assistant"):
104
+ role = "user" # Default to user for unknown roles
105
+
106
+ messages.append(ChatMessage(role=role, content=str(content)))
107
+
108
+ return messages
109
+
110
+
111
+ @router.post("/responses")
112
+ async def create_response(
113
+ request: ResponsesRequest,
114
+ provider: ClaudeCodeProvider = Depends(get_provider),
115
+ ):
116
+ """Create a response using the Responses API format.
117
+
118
+ This endpoint provides compatibility with the OpenAI Responses API.
119
+ """
120
+ actual_model = parse_model_name(request.model)
121
+
122
+ # Convert to chat messages
123
+ messages = convert_to_chat_messages(request)
124
+ response_id = f"resp-{uuid.uuid4().hex[:24]}"
125
+
126
+ if request.stream:
127
+ return StreamingResponse(
128
+ stream_response(
129
+ provider=provider,
130
+ messages=messages,
131
+ model=actual_model,
132
+ response_id=response_id,
133
+ ),
134
+ media_type="text/event-stream",
135
+ headers={
136
+ "Cache-Control": "no-cache",
137
+ "Connection": "keep-alive",
138
+ "X-Accel-Buffering": "no",
139
+ },
140
+ )
141
+ else:
142
+ # Non-streaming response
143
+ try:
144
+ result = await provider.execute(
145
+ messages=messages,
146
+ model=actual_model,
147
+ )
148
+ except TimeoutError as e:
149
+ raise HTTPException(status_code=504, detail=str(e))
150
+ except RuntimeError as e:
151
+ raise HTTPException(status_code=500, detail=str(e))
152
+
153
+ output_id = f"msg-{uuid.uuid4().hex[:24]}"
154
+
155
+ return ResponsesResponse(
156
+ id=response_id,
157
+ created_at=int(time.time()),
158
+ model=request.model,
159
+ output=[
160
+ ResponsesOutput(
161
+ id=output_id,
162
+ content=[{"type": "text", "text": result.content}],
163
+ )
164
+ ],
165
+ usage=result.usage,
166
+ )
167
+
168
+
169
+ async def stream_response(
170
+ provider: ClaudeCodeProvider,
171
+ messages: list[ChatMessage],
172
+ model: str,
173
+ response_id: str,
174
+ ) -> AsyncIterator[str]:
175
+ """Generate SSE events for streaming response."""
176
+ created = int(time.time())
177
+ output_id = f"msg-{uuid.uuid4().hex[:24]}"
178
+ content_buffer = ""
179
+
180
+ try:
181
+ # Send response.created event
182
+ yield sse_encode({
183
+ "type": "response.created",
184
+ "response": {
185
+ "id": response_id,
186
+ "object": "response",
187
+ "created_at": created,
188
+ "model": model,
189
+ "output": [],
190
+ }
191
+ })
192
+
193
+ # Send output_item.added event
194
+ yield sse_encode({
195
+ "type": "response.output_item.added",
196
+ "output_index": 0,
197
+ "item": {
198
+ "type": "message",
199
+ "id": output_id,
200
+ "role": "assistant",
201
+ "content": [],
202
+ }
203
+ })
204
+
205
+ # Stream content
206
+ async for chunk in provider.execute_stream(messages=messages, model=model):
207
+ if chunk.content:
208
+ content_buffer += chunk.content
209
+ yield sse_encode({
210
+ "type": "response.output_text.delta",
211
+ "output_index": 0,
212
+ "content_index": 0,
213
+ "delta": chunk.content,
214
+ })
215
+
216
+ # Send completion events
217
+ yield sse_encode({
218
+ "type": "response.output_text.done",
219
+ "output_index": 0,
220
+ "content_index": 0,
221
+ "text": content_buffer,
222
+ })
223
+
224
+ yield sse_encode({
225
+ "type": "response.output_item.done",
226
+ "output_index": 0,
227
+ "item": {
228
+ "type": "message",
229
+ "id": output_id,
230
+ "role": "assistant",
231
+ "content": [{"type": "text", "text": content_buffer}],
232
+ }
233
+ })
234
+
235
+ yield sse_encode({
236
+ "type": "response.completed",
237
+ "response": {
238
+ "id": response_id,
239
+ "object": "response",
240
+ "created_at": created,
241
+ "model": model,
242
+ "output": [{
243
+ "type": "message",
244
+ "id": output_id,
245
+ "role": "assistant",
246
+ "content": [{"type": "text", "text": content_buffer}],
247
+ }],
248
+ }
249
+ })
250
+
251
+ yield "data: [DONE]\n\n"
252
+
253
+ except Exception as e:
254
+ yield sse_error(str(e))
255
+ yield "data: [DONE]\n\n"
@@ -0,0 +1,5 @@
1
+ """Configuration module."""
2
+
3
+ from cli2api.config.settings import Settings
4
+
5
+ __all__ = ["Settings"]
@@ -0,0 +1,86 @@
1
+ """Application settings."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from pydantic import Field, field_validator
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ """Application settings loaded from environment variables."""
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_file=".env",
16
+ env_file_encoding="utf-8",
17
+ env_prefix="CLI2API_",
18
+ )
19
+
20
+ # Server
21
+ host: str = "0.0.0.0"
22
+ port: int = 8000
23
+ debug: bool = False
24
+
25
+ # CLI Path (auto-detect if not set)
26
+ claude_cli_path: Optional[str] = Field(
27
+ default=None,
28
+ description="Path to claude CLI executable",
29
+ )
30
+
31
+ # Timeout
32
+ default_timeout: int = Field(
33
+ default=300,
34
+ description="Default CLI execution timeout in seconds",
35
+ )
36
+
37
+ # Default model
38
+ default_model: str = Field(
39
+ default="sonnet",
40
+ description="Default model to use if not specified",
41
+ )
42
+
43
+ # Custom models (comma-separated, e.g. "sonnet,opus,haiku")
44
+ claude_models: Optional[str] = Field(
45
+ default=None,
46
+ description="Comma-separated list of Claude models to expose",
47
+ )
48
+
49
+ # Logging
50
+ log_level: str = "INFO"
51
+ log_json: bool = Field(
52
+ default=False,
53
+ description="Use JSON format for structured logging",
54
+ )
55
+
56
+ def get_claude_models(self) -> list[str]:
57
+ """Get list of Claude models.
58
+
59
+ Claude CLI doesn't have a models cache, so we use defaults
60
+ or user-configured models.
61
+ """
62
+ if self.claude_models:
63
+ return [m.strip() for m in self.claude_models.split(",")]
64
+ # Default Claude models (aliases supported by CLI)
65
+ return ["sonnet", "opus", "haiku"]
66
+
67
+ @field_validator("claude_cli_path", mode="before")
68
+ @classmethod
69
+ def detect_claude_cli(cls, v: Optional[str]) -> Optional[str]:
70
+ """Auto-detect claude CLI path if not provided."""
71
+ if v:
72
+ return v
73
+ # Try which first
74
+ path = shutil.which("claude")
75
+ if path:
76
+ return path
77
+ # Fallback to common paths
78
+ common_paths = [
79
+ "/opt/homebrew/bin/claude",
80
+ "/usr/local/bin/claude",
81
+ Path.home() / ".local/bin/claude",
82
+ ]
83
+ for p in common_paths:
84
+ if Path(p).exists():
85
+ return str(p)
86
+ return None
cli2api/main.py ADDED
@@ -0,0 +1,143 @@
1
+ """FastAPI application factory."""
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from importlib.metadata import version
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from cli2api.api.dependencies import get_provider, get_settings
13
+ from cli2api.api.router import api_router
14
+ from cli2api.utils.logging import get_logger, setup_logging
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ # Single source of truth for version from pyproject.toml
19
+ __version__ = version("cli2api")
20
+
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI):
24
+ """Application lifespan handler."""
25
+ settings = get_settings()
26
+ provider = get_provider()
27
+
28
+ # Setup structured logging
29
+ setup_logging(
30
+ level=settings.log_level,
31
+ json_format=settings.log_json,
32
+ logger_name=None, # Configure root logger
33
+ )
34
+
35
+ logger.info(
36
+ "Starting CLI2API",
37
+ extra={
38
+ "version": __version__,
39
+ "host": settings.host,
40
+ "port": settings.port,
41
+ "available_models": provider.supported_models,
42
+ },
43
+ )
44
+
45
+ yield
46
+
47
+ logger.info("CLI2API shutting down")
48
+
49
+
50
+ def create_app() -> FastAPI:
51
+ """Create and configure the FastAPI application.
52
+
53
+ Returns:
54
+ Configured FastAPI application.
55
+ """
56
+ app = FastAPI(
57
+ title="CLI2API",
58
+ description="OpenAI-compatible API over Claude Code CLI",
59
+ version=__version__,
60
+ lifespan=lifespan,
61
+ )
62
+
63
+ # CORS middleware for browser clients
64
+ app.add_middleware(
65
+ CORSMiddleware,
66
+ allow_origins=["*"],
67
+ allow_credentials=True,
68
+ allow_methods=["*"],
69
+ allow_headers=["*"],
70
+ )
71
+
72
+ # Include API routers
73
+ app.include_router(api_router)
74
+
75
+ # Validation error handler with logging
76
+ @app.exception_handler(RequestValidationError)
77
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
78
+ body = await request.body()
79
+ logger.error(f"Validation error for {request.method} {request.url}")
80
+ logger.error(f"Request body: {body.decode()[:1000]}")
81
+ logger.error(f"Validation errors: {exc.errors()}")
82
+ return JSONResponse(
83
+ status_code=422,
84
+ content={
85
+ "error": {
86
+ "message": "Invalid request format",
87
+ "type": "invalid_request_error",
88
+ "details": exc.errors(),
89
+ }
90
+ },
91
+ )
92
+
93
+ # Health check endpoint
94
+ @app.get("/health")
95
+ async def health():
96
+ """Health check endpoint with CLI availability check."""
97
+ settings = get_settings()
98
+
99
+ async def check_cli(path: str | None) -> str:
100
+ """Check if a CLI tool is available."""
101
+ if not path:
102
+ return "not_configured"
103
+ try:
104
+ proc = await asyncio.create_subprocess_exec(
105
+ path,
106
+ "--version",
107
+ stdout=asyncio.subprocess.PIPE,
108
+ stderr=asyncio.subprocess.PIPE,
109
+ )
110
+ await asyncio.wait_for(proc.communicate(), timeout=2.0)
111
+ return "available" if proc.returncode == 0 else "error"
112
+ except Exception:
113
+ return "unavailable"
114
+
115
+ claude_status = await check_cli(settings.claude_cli_path)
116
+
117
+ return {
118
+ "status": "healthy" if claude_status == "available" else "degraded",
119
+ "cli": {"claude": claude_status},
120
+ }
121
+
122
+ # Root endpoint
123
+ @app.get("/")
124
+ async def root():
125
+ """Root endpoint with API info."""
126
+ provider = get_provider()
127
+ return {
128
+ "name": "CLI2API",
129
+ "version": __version__,
130
+ "description": "OpenAI-compatible API over Claude Code CLI",
131
+ "available_models": [f"claude: {m}" for m in provider.supported_models],
132
+ "endpoints": {
133
+ "chat_completions": "/v1/chat/completions",
134
+ "models": "/v1/models",
135
+ "health": "/health",
136
+ },
137
+ }
138
+
139
+ return app
140
+
141
+
142
+ # Application instance
143
+ app = create_app()
@@ -0,0 +1,5 @@
1
+ """Claude Code CLI provider."""
2
+
3
+ from cli2api.providers.claude import ClaudeCodeProvider
4
+
5
+ __all__ = ["ClaudeCodeProvider"]