ac0459ffa5
- Temperature→effort mapping (0-0.2→low, 0.2-0.4→medium, 0.4-0.7→high, 0.7-0.9→xhigh, >=0.9→max); overridable via extra_body.effort - --fallback-model support via extra_body.fallback_model - Real cost reporting: x_claude_cost_usd in response JSON (from CLI total_cost_usd field); x_claude_rate_limit with utilization + resetsAt - Model aliases: sonnet, opus, haiku passed directly to --model flag - rate_limit_event parsed and forwarded in done event
584 lines
21 KiB
Python
584 lines
21 KiB
Python
"""
|
|
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",
|
|
# Short aliases accepted by the CLI
|
|
"sonnet",
|
|
"opus",
|
|
"haiku",
|
|
]
|
|
|
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
|
|
# Valid effort levels accepted by the CLI
|
|
_VALID_EFFORT_LEVELS = {"low", "medium", "high", "xhigh", "max"}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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: map temperature float to effort level string
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _temperature_to_effort(temperature: float) -> str:
|
|
if temperature < 0.2:
|
|
return "low"
|
|
elif temperature < 0.4:
|
|
return "medium"
|
|
elif temperature < 0.7:
|
|
return "high"
|
|
elif temperature < 0.9:
|
|
return "xhigh"
|
|
else:
|
|
return "max"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: build CLI command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_command(
|
|
model: str,
|
|
system_prompt: Optional[str],
|
|
effort: Optional[str] = None,
|
|
temperature: Optional[float] = None,
|
|
fallback_model: Optional[str] = None,
|
|
) -> list[str]:
|
|
cmd = [
|
|
CLAUDE_BIN,
|
|
"--print",
|
|
"--output-format", "stream-json",
|
|
"--input-format", "stream-json",
|
|
"--verbose",
|
|
"--include-partial-messages",
|
|
"--no-session-persistence",
|
|
"--mcp-config", "{\"mcpServers\":{}}",
|
|
"--strict-mcp-config",
|
|
"--tools", "",
|
|
"--model", model,
|
|
]
|
|
|
|
# Determine effort level to pass to CLI
|
|
resolved_effort: Optional[str] = None
|
|
if effort is not None and effort in _VALID_EFFORT_LEVELS:
|
|
resolved_effort = effort
|
|
elif temperature is not None:
|
|
resolved_effort = _temperature_to_effort(temperature)
|
|
# If both are None, don't add --effort (let CLI use its default)
|
|
|
|
if resolved_effort is not None:
|
|
cmd += ["--effort", resolved_effort]
|
|
|
|
if fallback_model is not None:
|
|
cmd += ["--fallback-model", fallback_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],
|
|
effort: Optional[str] = None,
|
|
temperature: Optional[float] = None,
|
|
fallback_model: Optional[str] = None,
|
|
) -> AsyncGenerator[dict, None]:
|
|
"""
|
|
Yields dicts with keys:
|
|
{"event": "delta", "text": "..."}
|
|
{"event": "done", "usage": {...}, "cost_usd": float | None, "rate_limit": dict | None}
|
|
{"event": "error", "status": int, "message": "..."}
|
|
"""
|
|
cmd = _build_command(model, system_prompt, effort=effort, temperature=temperature, fallback_model=fallback_model)
|
|
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 = {}
|
|
cost_usd: Optional[float] = None
|
|
rate_limit_info: Optional[dict] = None
|
|
|
|
# Read stdout line by line with overall timeout
|
|
async def _read_lines():
|
|
nonlocal last_text, usage, cost_usd, rate_limit_info
|
|
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)
|
|
),
|
|
}
|
|
# Extract cost from result event
|
|
raw_cost = event.get("total_cost_usd")
|
|
if raw_cost is not None:
|
|
try:
|
|
cost_usd = float(raw_cost)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
yield {
|
|
"event": "done",
|
|
"usage": usage,
|
|
"cost_usd": cost_usd,
|
|
"rate_limit": rate_limit_info,
|
|
}
|
|
|
|
elif etype == "rate_limit_event":
|
|
# Capture rate limit info for the done event
|
|
rate_limit_info = event.get("rate_limit_info", event)
|
|
log.debug("Rate limit event: %s", rate_limit_info)
|
|
|
|
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],
|
|
effort: Optional[str] = None,
|
|
temperature: Optional[float] = None,
|
|
fallback_model: Optional[str] = None,
|
|
) -> 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,
|
|
effort=effort, temperature=temperature, fallback_model=fallback_model,
|
|
):
|
|
# 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"
|
|
|
|
# Emit cost/rate-limit metadata chunk before [DONE]
|
|
cost_usd = ev.get("cost_usd")
|
|
rate_limit = ev.get("rate_limit")
|
|
if cost_usd is not None or rate_limit is not None:
|
|
meta_chunk = {
|
|
"id": completion_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": created,
|
|
"model": model,
|
|
"choices": [],
|
|
"usage": ev.get("usage", {}),
|
|
}
|
|
if cost_usd is not None:
|
|
meta_chunk["x_claude_cost_usd"] = cost_usd
|
|
if rate_limit is not None:
|
|
meta_chunk["x_claude_rate_limit"] = rate_limit
|
|
yield f"data: {json.dumps(meta_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))
|
|
|
|
# Extract temperature (kept for effort mapping; otherwise ignored)
|
|
temperature: Optional[float] = None
|
|
if "temperature" in body:
|
|
try:
|
|
temperature = float(body["temperature"])
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
# Extract effort from extra_body or top-level body
|
|
extra_body = body.get("extra_body") or {}
|
|
effort: Optional[str] = extra_body.get("effort") or body.get("effort")
|
|
if effort is not None:
|
|
effort = str(effort)
|
|
|
|
# Extract fallback_model from extra_body
|
|
fallback_model: Optional[str] = extra_body.get("fallback_model")
|
|
if fallback_model is not None:
|
|
fallback_model = str(fallback_model)
|
|
|
|
# Warn about unsupported / silently-ignored params
|
|
_IGNORED = {"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,
|
|
effort=effort, temperature=temperature, fallback_model=fallback_model,
|
|
)
|
|
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 = {}
|
|
cost_usd: Optional[float] = None
|
|
rate_limit_info: Optional[dict] = None
|
|
error_ev: Optional[dict] = None
|
|
|
|
async for ev in _run_claude(
|
|
model, system_prompt, turns,
|
|
effort=effort, temperature=temperature, fallback_model=fallback_model,
|
|
):
|
|
if ev["event"] == "delta":
|
|
full_text += ev["text"]
|
|
elif ev["event"] == "done":
|
|
usage = ev.get("usage", {})
|
|
cost_usd = ev.get("cost_usd")
|
|
rate_limit_info = ev.get("rate_limit")
|
|
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}"
|
|
response_body: dict = {
|
|
"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},
|
|
}
|
|
if cost_usd is not None:
|
|
response_body["x_claude_cost_usd"] = cost_usd
|
|
if rate_limit_info is not None:
|
|
response_body["x_claude_rate_limit"] = rate_limit_info
|
|
return response_body
|