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.
- cli2api/__init__.py +3 -0
- cli2api/__main__.py +22 -0
- cli2api/api/__init__.py +5 -0
- cli2api/api/dependencies.py +38 -0
- cli2api/api/router.py +12 -0
- cli2api/api/utils.py +15 -0
- cli2api/api/v1/__init__.py +5 -0
- cli2api/api/v1/chat.py +378 -0
- cli2api/api/v1/models.py +52 -0
- cli2api/api/v1/responses.py +255 -0
- cli2api/config/__init__.py +5 -0
- cli2api/config/settings.py +86 -0
- cli2api/main.py +143 -0
- cli2api/providers/__init__.py +5 -0
- cli2api/providers/claude.py +440 -0
- cli2api/schemas/__init__.py +36 -0
- cli2api/schemas/internal.py +25 -0
- cli2api/schemas/openai.py +229 -0
- cli2api/streaming/__init__.py +5 -0
- cli2api/streaming/sse.py +41 -0
- cli2api/tools/__init__.py +5 -0
- cli2api/tools/handler.py +396 -0
- cli2api/utils/__init__.py +0 -0
- cli2api/utils/logging.py +109 -0
- cli2api-0.1.0.dist-info/METADATA +217 -0
- cli2api-0.1.0.dist-info/RECORD +28 -0
- cli2api-0.1.0.dist-info/WHEEL +4 -0
- cli2api-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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,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()
|