Files
claude-max-bridge/server.py
T
fkrebs ac0459ffa5 feat: effort levels, fallback model, cost/rate-limit metadata
- 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
2026-05-22 16:46:06 +02:00

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