Initial implementation of claude-max-bridge
OpenAI-compatible FastAPI server wrapping the Claude Code CLI. Translates /v1/chat/completions requests into claude --print subprocess calls using stream-json input/output for multi-turn support and real streaming deltas. Features: - Full multi-turn conversation support via --input-format stream-json - Real-time streaming with --include-partial-messages delta tracking - Rate limit (429) and auth error (401) detection from CLI stderr - Configurable timeout, graceful subprocess cleanup on disconnect - /v1/models endpoint and /health check
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
.Python
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
*.tar.gz
|
||||||
|
*.whl
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.log
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir fastapi "uvicorn[standard]"
|
||||||
|
|
||||||
|
COPY server.py /app/server.py
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# claude binary and ~/.claude config dir are bind-mounted at runtime:
|
||||||
|
# -v /usr/local/bin/claude:/usr/local/bin/claude:ro
|
||||||
|
# -v /root/.claude:/root/.claude:ro
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -1,3 +1,92 @@
|
|||||||
# claude-max-bridge
|
# claude-max-bridge
|
||||||
|
|
||||||
OpenAI-compatible HTTP bridge over the Claude Code CLI — connects LiteLLM to the Claude Max subscription
|
A small FastAPI server that exposes an **OpenAI-compatible `/v1/chat/completions`
|
||||||
|
endpoint** and translates each request into a `claude` CLI subprocess call.
|
||||||
|
This lets LiteLLM (or any OpenAI-compatible client) consume a Claude Max
|
||||||
|
subscription through the locally-installed `claude` binary without needing an
|
||||||
|
Anthropic API key.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. Client sends an OpenAI chat-completions request (streaming or non-streaming).
|
||||||
|
2. The bridge extracts the system prompt and conversation turns.
|
||||||
|
3. It spawns `claude --print --output-format stream-json --input-format stream-json ...`
|
||||||
|
and pipes the conversation as NDJSON to stdin.
|
||||||
|
4. It parses the NDJSON stdout, computes text deltas from partial assistant
|
||||||
|
messages, and re-emits them as OpenAI SSE chunks (or buffers for
|
||||||
|
non-streaming).
|
||||||
|
5. Usage totals from the `result` event are forwarded in the final response.
|
||||||
|
|
||||||
|
Authentication is handled entirely by the `claude` binary; the bridge just
|
||||||
|
bind-mounts the existing `~/.claude` credentials directory.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---------------------|------------------------|--------------------------------------------------|
|
||||||
|
| `CLAUDE_BIN` | `/usr/local/bin/claude`| Path to the `claude` CLI binary |
|
||||||
|
| `CLAUDE_HOME` | `/root/.claude` | Path to the Claude config / credentials directory|
|
||||||
|
| `LOG_LEVEL` | `INFO` | Python logging level |
|
||||||
|
| `REQUEST_TIMEOUT_S` | `120` | Per-request subprocess timeout in seconds |
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t claude-max-bridge .
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name claude-max-bridge \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v /usr/local/bin/claude:/usr/local/bin/claude:ro \
|
||||||
|
-v /root/.claude:/root/.claude:ro \
|
||||||
|
claude-max-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported models
|
||||||
|
|
||||||
|
- `claude-sonnet-4-6` (default fallback)
|
||||||
|
- `claude-opus-4-5`
|
||||||
|
- `claude-haiku-4-5`
|
||||||
|
- `claude-opus-4-0`
|
||||||
|
- `claude-sonnet-3-7`
|
||||||
|
- `claude-haiku-3-5`
|
||||||
|
|
||||||
|
Unknown model names in requests fall back to `claude-sonnet-4-6`.
|
||||||
|
|
||||||
|
## LiteLLM configuration
|
||||||
|
|
||||||
|
Add these entries to your `litellm_config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
- model_name: claude-sonnet-4-6
|
||||||
|
litellm_params:
|
||||||
|
model: openai/claude-sonnet-4-6
|
||||||
|
api_base: http://claude-max-bridge:8000/v1
|
||||||
|
api_key: "unused" # required by LiteLLM but ignored by bridge
|
||||||
|
|
||||||
|
- model_name: claude-opus-4-5
|
||||||
|
litellm_params:
|
||||||
|
model: openai/claude-opus-4-5
|
||||||
|
api_base: http://claude-max-bridge:8000/v1
|
||||||
|
api_key: "unused"
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `claude-max-bridge` with the actual hostname or IP where the bridge
|
||||||
|
container is running.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------------------------|------------------------------------------|
|
||||||
|
| GET | `/health` | Liveness check, always returns `200 OK` |
|
||||||
|
| GET | `/v1/models` | Lists supported models |
|
||||||
|
| POST | `/v1/chat/completions` | OpenAI-compatible chat completions |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `temperature`, `top_p`, and other sampling parameters are silently ignored
|
||||||
|
(logged at DEBUG level) because the CLI does not expose them.
|
||||||
|
- Rate-limit responses from the CLI are translated to HTTP `429`.
|
||||||
|
- Auth errors from the CLI are translated to HTTP `401`.
|
||||||
|
- The subprocess is killed on client disconnect or timeout.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
"""
|
||||||
|
claude-max-bridge: OpenAI-compatible API server that translates requests
|
||||||
|
to Claude Code CLI subprocess calls, enabling use of a Claude Max subscription
|
||||||
|
via LiteLLM or any OpenAI-compatible client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "/usr/local/bin/claude")
|
||||||
|
CLAUDE_HOME = os.environ.get("CLAUDE_HOME", "/root/.claude")
|
||||||
|
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
|
||||||
|
REQUEST_TIMEOUT_S = float(os.environ.get("REQUEST_TIMEOUT_S", "120"))
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("claude-max-bridge")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Supported models (pass-through to CLI --model flag)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SUPPORTED_MODELS = [
|
||||||
|
"claude-opus-4-5",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"claude-haiku-4-5",
|
||||||
|
"claude-opus-4-0",
|
||||||
|
"claude-sonnet-3-7",
|
||||||
|
"claude-haiku-3-5",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(title="claude-max-bridge", version="1.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: build subprocess env
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _subprocess_env() -> dict:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = "/root"
|
||||||
|
env["XDG_CONFIG_HOME"] = os.path.join(CLAUDE_HOME, "..") # ~/.claude lives at CLAUDE_HOME
|
||||||
|
# Ensure the claude binary can find its config
|
||||||
|
env["CLAUDE_CONFIG_DIR"] = CLAUDE_HOME
|
||||||
|
# Prevent interactive prompts
|
||||||
|
env["TERM"] = "dumb"
|
||||||
|
env.pop("DISPLAY", None)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: parse OpenAI messages → (system_prompt, conversation_ndjson)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_messages(messages: list[dict]) -> tuple[Optional[str], list[dict]]:
|
||||||
|
"""
|
||||||
|
Split OpenAI messages into:
|
||||||
|
- system_prompt: first system message content (str | None)
|
||||||
|
- turns: list of {type, message} dicts suitable for NDJSON stdin
|
||||||
|
"""
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
turns: list[dict] = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
|
||||||
|
# Normalise content: list-of-parts → plain string
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_parts = []
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text":
|
||||||
|
text_parts.append(part.get("text", ""))
|
||||||
|
elif isinstance(part, str):
|
||||||
|
text_parts.append(part)
|
||||||
|
content = "".join(text_parts)
|
||||||
|
|
||||||
|
if role == "system":
|
||||||
|
if system_prompt is None:
|
||||||
|
system_prompt = content
|
||||||
|
else:
|
||||||
|
# Additional system messages: prepend to next user turn or skip
|
||||||
|
log.debug("Ignoring additional system message: %s", content[:80])
|
||||||
|
elif role == "user":
|
||||||
|
turns.append({"type": "user", "message": {"role": "user", "content": content}})
|
||||||
|
elif role == "assistant":
|
||||||
|
turns.append({"type": "assistant", "message": {"role": "assistant", "content": content}})
|
||||||
|
else:
|
||||||
|
log.debug("Ignoring unknown role: %s", role)
|
||||||
|
|
||||||
|
return system_prompt, turns
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: build CLI command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_command(model: str, system_prompt: Optional[str]) -> list[str]:
|
||||||
|
cmd = [
|
||||||
|
CLAUDE_BIN,
|
||||||
|
"--print",
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--input-format", "stream-json",
|
||||||
|
"--include-partial-messages",
|
||||||
|
"--no-session-persistence",
|
||||||
|
"--tools", "",
|
||||||
|
"--model", model,
|
||||||
|
]
|
||||||
|
if system_prompt:
|
||||||
|
cmd += ["--system-prompt", system_prompt]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core: run subprocess and stream parsed events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run_claude(
|
||||||
|
model: str,
|
||||||
|
system_prompt: Optional[str],
|
||||||
|
turns: list[dict],
|
||||||
|
) -> AsyncGenerator[dict, None]:
|
||||||
|
"""
|
||||||
|
Yields dicts with keys:
|
||||||
|
{"event": "delta", "text": "..."}
|
||||||
|
{"event": "done", "usage": {...}}
|
||||||
|
{"event": "error", "status": int, "message": "..."}
|
||||||
|
"""
|
||||||
|
cmd = _build_command(model, system_prompt)
|
||||||
|
log.debug("Spawning: %s", " ".join(cmd[:6]) + " ...")
|
||||||
|
env = _subprocess_env()
|
||||||
|
|
||||||
|
proc: Optional[asyncio.subprocess.Process] = None
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write conversation turns as NDJSON then close stdin
|
||||||
|
stdin_data = "\n".join(json.dumps(t) for t in turns) + "\n"
|
||||||
|
proc.stdin.write(stdin_data.encode())
|
||||||
|
await proc.stdin.drain()
|
||||||
|
proc.stdin.close()
|
||||||
|
|
||||||
|
last_text = ""
|
||||||
|
usage: dict = {}
|
||||||
|
|
||||||
|
# Read stdout line by line with overall timeout
|
||||||
|
async def _read_lines():
|
||||||
|
nonlocal last_text, usage
|
||||||
|
assert proc is not None
|
||||||
|
assert proc.stdout is not None
|
||||||
|
async for raw_line in proc.stdout:
|
||||||
|
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log.warning("Non-JSON stdout line: %s", line[:200])
|
||||||
|
continue
|
||||||
|
|
||||||
|
etype = event.get("type", "")
|
||||||
|
|
||||||
|
if etype == "assistant":
|
||||||
|
msg = event.get("message", {})
|
||||||
|
content = msg.get("content", [])
|
||||||
|
# content is a list; find the first text block
|
||||||
|
current_text = ""
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
current_text = block.get("text", "")
|
||||||
|
break
|
||||||
|
if current_text and current_text != last_text:
|
||||||
|
delta = current_text[len(last_text):]
|
||||||
|
last_text = current_text
|
||||||
|
if delta:
|
||||||
|
yield {"event": "delta", "text": delta}
|
||||||
|
|
||||||
|
elif etype == "result":
|
||||||
|
raw_usage = event.get("usage", {})
|
||||||
|
usage = {
|
||||||
|
"prompt_tokens": raw_usage.get("input_tokens", 0),
|
||||||
|
"completion_tokens": raw_usage.get("output_tokens", 0),
|
||||||
|
"total_tokens": (
|
||||||
|
raw_usage.get("input_tokens", 0)
|
||||||
|
+ raw_usage.get("output_tokens", 0)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
yield {"event": "done", "usage": usage}
|
||||||
|
|
||||||
|
elif etype == "system":
|
||||||
|
subtype = event.get("subtype", "")
|
||||||
|
if subtype == "error":
|
||||||
|
err_msg = event.get("error", {}).get("message", "Unknown CLI error")
|
||||||
|
yield {"event": "error", "status": 500, "message": err_msg}
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(REQUEST_TIMEOUT_S):
|
||||||
|
async for ev in _read_lines():
|
||||||
|
yield ev
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error("Subprocess timed out after %.0fs", REQUEST_TIMEOUT_S)
|
||||||
|
yield {"event": "error", "status": 504, "message": f"Claude CLI timed out after {REQUEST_TIMEOUT_S:.0f}s"}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for process to finish and collect stderr
|
||||||
|
try:
|
||||||
|
_, stderr_bytes = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=10.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stderr_bytes = b""
|
||||||
|
|
||||||
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace") if stderr_bytes else ""
|
||||||
|
rc = proc.returncode
|
||||||
|
|
||||||
|
if rc != 0:
|
||||||
|
log.error("Claude CLI exited %d. stderr: %s", rc, stderr_text[:500])
|
||||||
|
stderr_lower = stderr_text.lower()
|
||||||
|
if "rate limit" in stderr_lower or (rc == 1 and "usage limit" in stderr_lower):
|
||||||
|
yield {"event": "error", "status": 429, "message": stderr_text or "Rate limit exceeded"}
|
||||||
|
elif "not logged in" in stderr_lower or "authentication" in stderr_lower:
|
||||||
|
yield {"event": "error", "status": 401, "message": "Claude CLI authentication error"}
|
||||||
|
else:
|
||||||
|
yield {"event": "error", "status": 500, "message": f"Claude CLI error (exit {rc}): {stderr_text[:300]}"}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Unexpected error running Claude CLI")
|
||||||
|
yield {"event": "error", "status": 500, "message": str(exc)}
|
||||||
|
finally:
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
log.debug("Killing lingering subprocess")
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Streaming response builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _stream_response(
|
||||||
|
request: Request,
|
||||||
|
model: str,
|
||||||
|
system_prompt: Optional[str],
|
||||||
|
turns: list[dict],
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
||||||
|
created = int(time.time())
|
||||||
|
|
||||||
|
async def _gen():
|
||||||
|
got_done = False
|
||||||
|
async for ev in _run_claude(model, system_prompt, turns):
|
||||||
|
# Check if client disconnected
|
||||||
|
if await request.is_disconnected():
|
||||||
|
log.info("Client disconnected, stopping stream")
|
||||||
|
return
|
||||||
|
|
||||||
|
if ev["event"] == "delta":
|
||||||
|
chunk = {
|
||||||
|
"id": completion_id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"model": model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"content": ev["text"]},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(chunk)}\n\n"
|
||||||
|
|
||||||
|
elif ev["event"] == "done":
|
||||||
|
got_done = True
|
||||||
|
# Final chunk with finish_reason
|
||||||
|
final_chunk = {
|
||||||
|
"id": completion_id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"model": model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": ev.get("usage", {}),
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(final_chunk)}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
elif ev["event"] == "error":
|
||||||
|
# Emit an error chunk — client will see it in the stream
|
||||||
|
err_chunk = {
|
||||||
|
"id": completion_id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"model": model,
|
||||||
|
"choices": [],
|
||||||
|
"error": {"type": "server_error", "message": ev["message"]},
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(err_chunk)}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
if not got_done:
|
||||||
|
# Ensure stream is always terminated
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models():
|
||||||
|
now = int(time.time())
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": m,
|
||||||
|
"object": "model",
|
||||||
|
"created": now,
|
||||||
|
"owned_by": "anthropic",
|
||||||
|
}
|
||||||
|
for m in SUPPORTED_MODELS
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/chat/completions")
|
||||||
|
async def chat_completions(request: Request):
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
# --- Parse request ---
|
||||||
|
messages = body.get("messages", [])
|
||||||
|
if not messages:
|
||||||
|
raise HTTPException(status_code=400, detail="messages must not be empty")
|
||||||
|
|
||||||
|
raw_model = body.get("model", DEFAULT_MODEL)
|
||||||
|
# Accept model aliases / prefixes gracefully
|
||||||
|
model = raw_model if raw_model in SUPPORTED_MODELS else DEFAULT_MODEL
|
||||||
|
if model != raw_model:
|
||||||
|
log.debug("Unknown model %r, falling back to %s", raw_model, model)
|
||||||
|
|
||||||
|
stream = bool(body.get("stream", False))
|
||||||
|
|
||||||
|
# Warn about unsupported / silently-ignored params
|
||||||
|
_IGNORED = {"temperature", "top_p", "frequency_penalty", "presence_penalty",
|
||||||
|
"logit_bias", "n", "best_of", "logprobs", "top_logprobs",
|
||||||
|
"response_format", "seed", "stop", "user"}
|
||||||
|
for param in _IGNORED:
|
||||||
|
if param in body:
|
||||||
|
log.debug("Ignoring unsupported parameter: %s", param)
|
||||||
|
|
||||||
|
system_prompt, turns = _parse_messages(messages)
|
||||||
|
|
||||||
|
if not turns:
|
||||||
|
raise HTTPException(status_code=400, detail="No user/assistant turns found in messages")
|
||||||
|
|
||||||
|
# --- Streaming ---
|
||||||
|
if stream:
|
||||||
|
gen = await _stream_response(request, model, system_prompt, turns)
|
||||||
|
return StreamingResponse(
|
||||||
|
gen,
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Non-streaming: buffer all deltas ---
|
||||||
|
full_text = ""
|
||||||
|
usage: dict = {}
|
||||||
|
error_ev: Optional[dict] = None
|
||||||
|
|
||||||
|
async for ev in _run_claude(model, system_prompt, turns):
|
||||||
|
if ev["event"] == "delta":
|
||||||
|
full_text += ev["text"]
|
||||||
|
elif ev["event"] == "done":
|
||||||
|
usage = ev.get("usage", {})
|
||||||
|
elif ev["event"] == "error":
|
||||||
|
error_ev = ev
|
||||||
|
break
|
||||||
|
|
||||||
|
if error_ev:
|
||||||
|
status_code = error_ev.get("status", 500)
|
||||||
|
err_type = {
|
||||||
|
429: "rate_limit_error",
|
||||||
|
401: "authentication_error",
|
||||||
|
}.get(status_code, "server_error")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content={"error": {"type": err_type, "message": error_ev["message"]}},
|
||||||
|
)
|
||||||
|
|
||||||
|
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
||||||
|
return {
|
||||||
|
"id": completion_id,
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": int(time.time()),
|
||||||
|
"model": model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": full_text},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": usage or {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user