| |
|
| | """
|
| | Puter.com Reverse OpenAI-Compatible API Server
|
| |
|
| | Accepts OpenAI Chat Completions requests and forwards them to:
|
| | POST https://api.puter.com/drivers/call
|
| |
|
| | with payload:
|
| | {
|
| | "interface": "puter-chat-completion",
|
| | "driver": "claude",
|
| | "test_mode": false,
|
| | "method": "complete",
|
| | "args": {
|
| | "messages": [{"content": "..."}],
|
| | "model": "claude-sonnet-4-20250514",
|
| | "stream": true
|
| | }
|
| | }
|
| | """
|
| | import json
|
| | import time
|
| | import uuid
|
| | import logging
|
| | from typing import Any, Dict, List, Optional, Union, AsyncGenerator
|
| |
|
| | import requests
|
| | from fastapi import FastAPI, HTTPException, Request
|
| | from fastapi.middleware.cors import CORSMiddleware
|
| | from fastapi.responses import StreamingResponse, JSONResponse
|
| | from pydantic import BaseModel, Field
|
| |
|
| | try:
|
| | from .config import (
|
| | PUTER_HEADERS,
|
| | PUTER_AUTH_BEARER,
|
| | SERVER_CONFIG,
|
| | MODEL_MAPPING,
|
| | )
|
| | except ImportError:
|
| | from config import (
|
| | PUTER_HEADERS,
|
| | PUTER_AUTH_BEARER,
|
| | SERVER_CONFIG,
|
| | MODEL_MAPPING,
|
| | )
|
| |
|
| | logger = logging.getLogger(__name__)
|
| | logging.basicConfig(level=logging.INFO)
|
| |
|
| | PUTER_URL = "https://api.puter.com/drivers/call"
|
| | REQUEST_TIMEOUT = 120
|
| |
|
| |
|
| |
|
| | class OpenAIMessage(BaseModel):
|
| | role: Optional[str] = Field(default=None, description="Role")
|
| | content: Optional[Union[str, List[Dict[str, Any]]]] = None
|
| | name: Optional[str] = None
|
| | function_call: Optional[Dict[str, Any]] = None
|
| | tool_calls: Optional[List[Dict[str, Any]]] = None
|
| | tool_call_id: Optional[str] = None
|
| |
|
| | def get_text(self) -> str:
|
| | if isinstance(self.content, str):
|
| | return self.content
|
| | if isinstance(self.content, list):
|
| | parts: List[str] = []
|
| | for item in self.content:
|
| | if isinstance(item, dict) and item.get("type") == "text":
|
| | parts.append(item.get("text", ""))
|
| | return "".join(parts)
|
| | return str(self.content) if self.content is not None else ""
|
| |
|
| | class Config:
|
| | extra = "allow"
|
| |
|
| |
|
| | class OpenAIFunction(BaseModel):
|
| | name: str
|
| | description: Optional[str] = None
|
| | parameters: Optional[Dict[str, Any]] = None
|
| |
|
| | class Config:
|
| | extra = "allow"
|
| |
|
| |
|
| | class OpenAITool(BaseModel):
|
| | type: str = Field(default="function")
|
| | function: Optional[OpenAIFunction] = None
|
| |
|
| | class Config:
|
| | extra = "allow"
|
| |
|
| |
|
| | class OpenAIChatRequest(BaseModel):
|
| | model: str
|
| | messages: List[OpenAIMessage]
|
| | max_tokens: Optional[int] = None
|
| | temperature: Optional[float] = None
|
| | top_p: Optional[float] = None
|
| | n: Optional[int] = 1
|
| | stream: Optional[bool] = False
|
| | stop: Optional[Union[str, List[str]]] = None
|
| | presence_penalty: Optional[float] = None
|
| | frequency_penalty: Optional[float] = None
|
| | logit_bias: Optional[Dict[str, float]] = None
|
| | user: Optional[str] = None
|
| | tools: Optional[List[OpenAITool]] = None
|
| | tool_choice: Optional[Union[str, Dict[str, Any]]] = None
|
| | functions: Optional[List[OpenAIFunction]] = None
|
| | function_call: Optional[Union[str, Dict[str, Any]]] = None
|
| |
|
| | class Config:
|
| | extra = "allow"
|
| |
|
| |
|
| | class OpenAIChoice(BaseModel):
|
| | index: int = 0
|
| | message: Dict[str, Any]
|
| | finish_reason: Optional[str] = None
|
| |
|
| |
|
| | class OpenAIChatResponse(BaseModel):
|
| | id: str
|
| | object: str = "chat.completion"
|
| | created: int
|
| | model: str
|
| | choices: List[OpenAIChoice]
|
| | usage: Optional[Dict[str, int]] = None
|
| |
|
| |
|
| | class OpenAIStreamChoice(BaseModel):
|
| | index: int = 0
|
| | delta: Dict[str, Any]
|
| | finish_reason: Optional[str] = None
|
| |
|
| |
|
| | class OpenAIStreamChunk(BaseModel):
|
| | id: str
|
| | object: str = "chat.completion.chunk"
|
| | created: int
|
| | model: str
|
| | choices: List[OpenAIStreamChoice]
|
| |
|
| |
|
| | def _build_puter_payload(openai_req: OpenAIChatRequest) -> Dict[str, Any]:
|
| |
|
| | mapped_messages: List[Dict[str, str]] = []
|
| | for m in openai_req.messages:
|
| | txt = m.get_text()
|
| | mapped_messages.append({"content": txt})
|
| |
|
| |
|
| | mapping = MODEL_MAPPING.get(openai_req.model) or MODEL_MAPPING.get("default")
|
| | driver = mapping["driver"]
|
| | puter_model = mapping["puter_model"]
|
| |
|
| | payload: Dict[str, Any] = {
|
| | "interface": "puter-chat-completion",
|
| | "driver": driver,
|
| | "test_mode": False,
|
| | "method": "complete",
|
| | "args": {
|
| | "messages": mapped_messages,
|
| | "model": puter_model,
|
| | "stream": True,
|
| | },
|
| | }
|
| | return payload
|
| |
|
| |
|
| | def _headers_with_auth() -> Dict[str, str]:
|
| | h = dict(PUTER_HEADERS)
|
| | h["authorization"] = f"Bearer {PuterAuth.token}"
|
| | return h
|
| |
|
| |
|
| | class PuterAuth:
|
| | token: str = PUTER_AUTH_BEARER
|
| |
|
| |
|
| | async def _stream_openai_chunks(openai_req: OpenAIChatRequest, request_id: str) -> AsyncGenerator[str, None]:
|
| | headers = _headers_with_auth()
|
| | payload = _build_puter_payload(openai_req)
|
| |
|
| | with requests.Session() as sess:
|
| | try:
|
| | resp = sess.post(
|
| | PUTER_URL,
|
| | headers=headers,
|
| | json=payload,
|
| | stream=True,
|
| | timeout=REQUEST_TIMEOUT,
|
| | )
|
| | except requests.RequestException as e:
|
| | raise HTTPException(status_code=502, detail=f"Upstream connection error: {e}")
|
| |
|
| | if resp.status_code != 200:
|
| | detail = resp.text[:500]
|
| | raise HTTPException(status_code=502, detail=f"Upstream error {resp.status_code}: {detail}")
|
| |
|
| | created = int(time.time())
|
| |
|
| |
|
| | initial = OpenAIStreamChunk(
|
| | id=request_id,
|
| | created=created,
|
| | model=openai_req.model,
|
| | choices=[OpenAIStreamChoice(index=0, delta={"role": "assistant"}, finish_reason=None)],
|
| | )
|
| | yield f"data: {initial.model_dump_json()}\n\n"
|
| |
|
| |
|
| | for raw in resp.iter_lines():
|
| | if not raw:
|
| | continue
|
| | try:
|
| | line = raw.decode("utf-8", errors="ignore")
|
| | except Exception:
|
| | continue
|
| |
|
| | text_piece: Optional[str] = None
|
| |
|
| | try:
|
| | obj = json.loads(line)
|
| |
|
| | for k in ("delta", "text", "content", "output"):
|
| | if isinstance(obj.get(k), str) and obj.get(k):
|
| | text_piece = obj.get(k)
|
| | break
|
| | except Exception:
|
| |
|
| | if line and line != "[DONE]":
|
| | text_piece = line
|
| |
|
| | if not text_piece:
|
| | continue
|
| |
|
| | chunk = OpenAIStreamChunk(
|
| | id=request_id,
|
| | created=created,
|
| | model=openai_req.model,
|
| | choices=[OpenAIStreamChoice(index=0, delta={"content": text_piece}, finish_reason=None)],
|
| | )
|
| | yield f"data: {chunk.model_dump_json()}\n\n"
|
| |
|
| | final = OpenAIStreamChunk(
|
| | id=request_id,
|
| | created=created,
|
| | model=openai_req.model,
|
| | choices=[OpenAIStreamChoice(index=0, delta={}, finish_reason="stop")],
|
| | )
|
| | yield f"data: {final.model_dump_json()}\n\n"
|
| | yield "data: [DONE]\n\n"
|
| |
|
| |
|
| | def _complete_non_streaming(openai_req: OpenAIChatRequest) -> str:
|
| | headers = _headers_with_auth()
|
| | payload = _build_puter_payload(openai_req)
|
| | payload["args"]["stream"] = True
|
| |
|
| | with requests.Session() as sess:
|
| | try:
|
| | resp = sess.post(
|
| | PUTER_URL,
|
| | headers=headers,
|
| | json=payload,
|
| | stream=True,
|
| | timeout=REQUEST_TIMEOUT,
|
| | )
|
| | except requests.RequestException as e:
|
| | raise HTTPException(status_code=502, detail=f"Upstream connection error: {e}")
|
| |
|
| | if resp.status_code != 200:
|
| | detail = resp.text[:500]
|
| | raise HTTPException(status_code=502, detail=f"Upstream error {resp.status_code}: {detail}")
|
| |
|
| | parts: List[str] = []
|
| | for raw in resp.iter_lines():
|
| | if not raw:
|
| | continue
|
| | try:
|
| | line = raw.decode("utf-8", errors="ignore")
|
| | except Exception:
|
| | continue
|
| | try:
|
| | obj = json.loads(line)
|
| | for k in ("delta", "text", "content", "output"):
|
| | if isinstance(obj.get(k), str) and obj.get(k):
|
| | parts.append(obj.get(k))
|
| | break
|
| | except Exception:
|
| | if line and line != "[DONE]":
|
| | parts.append(line)
|
| | return "".join(parts)
|
| |
|
| |
|
| |
|
| | app = FastAPI(
|
| | title="Puter Reverse OpenAI API",
|
| | version="1.0.0",
|
| | description="OpenAI-compatible API proxying to api.puter.com"
|
| | )
|
| |
|
| | app.add_middleware(
|
| | CORSMiddleware,
|
| | allow_origins=["*"],
|
| | allow_credentials=True,
|
| | allow_methods=["*"],
|
| | allow_headers=["*"],
|
| | )
|
| |
|
| |
|
| | @app.get("/")
|
| | async def root():
|
| | return {"message": "Puter Reverse OpenAI API", "status": "running", "version": "1.0.0"}
|
| |
|
| |
|
| | @app.get("/health")
|
| | async def health():
|
| | return {"status": "healthy", "timestamp": int(time.time())}
|
| |
|
| |
|
| | @app.get("/v1/models")
|
| | async def models():
|
| | created = int(time.time())
|
| | data = []
|
| | for key in [k for k in MODEL_MAPPING.keys() if k != "default"]:
|
| | data.append({"id": key, "object": "model", "created": created, "owned_by": "puter"})
|
| | if not data:
|
| | data.append({"id": "claude-sonnet-4-20250514", "object": "model", "created": created, "owned_by": "puter"})
|
| | return {"object": "list", "data": data}
|
| |
|
| |
|
| | @app.post("/v1/chat/completions")
|
| | async def chat(request: OpenAIChatRequest):
|
| | req_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
| | logger.info(f"[{req_id}] model={request.model}, stream={bool(request.stream)}")
|
| |
|
| | if bool(request.stream):
|
| | return StreamingResponse(
|
| | _stream_openai_chunks(request, req_id),
|
| | media_type="text/event-stream",
|
| | headers={
|
| | "Cache-Control": "no-cache",
|
| | "Connection": "keep-alive",
|
| | "X-Accel-Buffering": "no",
|
| | "Access-Control-Allow-Origin": "*",
|
| | "Access-Control-Allow-Headers": "*",
|
| | },
|
| | )
|
| |
|
| | content = _complete_non_streaming(request)
|
| | created = int(time.time())
|
| | response = OpenAIChatResponse(
|
| | id=req_id,
|
| | created=created,
|
| | model=request.model,
|
| | choices=[OpenAIChoice(index=0, message={"role": "assistant", "content": content}, finish_reason="stop")],
|
| | usage={
|
| | "prompt_tokens": len(" ".join([m.get_text() for m in request.messages]).split()),
|
| | "completion_tokens": len(content.split()),
|
| | "total_tokens": len(" ".join([m.get_text() for m in request.messages]).split()) + len(content.split()),
|
| | },
|
| | )
|
| | return response
|
| |
|
| |
|
| | @app.post("/v1/chat/completions/raw")
|
| | async def raw(req: Request):
|
| | body = await req.body()
|
| | try:
|
| | obj = json.loads(body)
|
| | _ = OpenAIChatRequest(**obj)
|
| | return {"valid": True}
|
| | except Exception as e:
|
| | return JSONResponse(status_code=422, content={"valid": False, "error": str(e)})
|
| |
|
| |
|
| | if __name__ == "__main__":
|
| | try:
|
| | import uvicorn
|
| | host = SERVER_CONFIG.get("host", "0.0.0.0")
|
| | port = int(SERVER_CONFIG.get("port", 8781))
|
| | logger.info(f"Starting Puter Reverse API on {host}:{port}")
|
| | uvicorn.run(app, host=host, port=port, log_level="info")
|
| | except Exception as e:
|
| | logger.error(f"Failed to start server: {e}")
|
| |
|
| |
|
| |
|