mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-20 16:47:40 +02:00
155 lines
5.2 KiB
Python
155 lines
5.2 KiB
Python
"""Tiny OpenAI-API pass-through with `max_tokens` → `max_completion_tokens`
|
|
rename for GPT-5.x models.
|
|
|
|
Why this exists
|
|
---------------
|
|
OpenAI's GPT-5 family (gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, etc.)
|
|
rejects the legacy `max_tokens` parameter with HTTP 400:
|
|
"Unsupported parameter: 'max_tokens' is not supported with this model.
|
|
Use 'max_completion_tokens'."
|
|
|
|
Anthropic's CLI emits requests in Anthropic format (which uses `max_tokens`),
|
|
9Router 0.3.60 translates Anthropic→OpenAI and preserves `max_tokens`
|
|
(it doesn't know about the GPT-5 change). We can't bump 9Router because
|
|
0.3.60 is pinned to fix a separate WebSearch regression in the 0.3.x
|
|
range (see backend/apps/nine_router.py:27-36).
|
|
|
|
So we slot a thin proxy between 9Router and api.openai.com. The CLI is
|
|
unaware: it sees its OPENAI_BASE_URL pointing at this local passthrough,
|
|
not OpenAI. We rename the field for GPT-5 models and forward unchanged
|
|
otherwise. Streaming + non-streaming both work because we proxy bytes.
|
|
|
|
Mounted at `/api/openai-passthrough` and consumed by setting
|
|
OPENAI_BASE_URL to `http://127.0.0.1:<port>/api/openai-passthrough/v1`
|
|
in the CLI's spawn env (see agent_manager.py).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
from backend.config.Apps import SubApp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def openai_passthrough_lifespan():
|
|
yield
|
|
|
|
|
|
openai_passthrough = SubApp("openai-passthrough", openai_passthrough_lifespan)
|
|
|
|
|
|
# Models that REQUIRE max_completion_tokens. Mirrors anthropic_proxy.py's
|
|
# matcher but lives here so this module doesn't depend on that one.
|
|
_GPT5_PREFIXES = ("gpt-5",)
|
|
_OPENAI_UPSTREAM = "https://api.openai.com/v1"
|
|
_HOP_HEADERS = {
|
|
"host", "content-length", "connection", "keep-alive",
|
|
"proxy-authenticate", "proxy-authorization", "te", "trailers",
|
|
"transfer-encoding", "upgrade",
|
|
}
|
|
|
|
|
|
def _is_gpt5(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
if not m:
|
|
return False
|
|
# Strip routing prefixes 9Router may have added.
|
|
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
|
|
if m.startswith(prefix):
|
|
m = m[len(prefix):]
|
|
break
|
|
return any(m.startswith(p) for p in _GPT5_PREFIXES)
|
|
|
|
|
|
def _scrub_max_tokens(body: bytes) -> bytes:
|
|
"""Rename max_tokens → max_completion_tokens for GPT-5 models.
|
|
|
|
Bytes-in/out, never raises. No-op if body isn't JSON, model isn't GPT-5,
|
|
or max_tokens isn't present. If both fields are present (unlikely),
|
|
drops the legacy field so OpenAI doesn't 400 on the conflict.
|
|
"""
|
|
if not body:
|
|
return body
|
|
try:
|
|
parsed = json.loads(body)
|
|
except Exception:
|
|
return body
|
|
if not isinstance(parsed, dict):
|
|
return body
|
|
model = str(parsed.get("model") or "")
|
|
if not _is_gpt5(model):
|
|
return body
|
|
if "max_tokens" in parsed and "max_completion_tokens" not in parsed:
|
|
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
|
|
return json.dumps(parsed).encode("utf-8")
|
|
if "max_tokens" in parsed and "max_completion_tokens" in parsed:
|
|
parsed.pop("max_tokens", None)
|
|
return json.dumps(parsed).encode("utf-8")
|
|
return body
|
|
|
|
|
|
@openai_passthrough.router.api_route(
|
|
"/v1/{rest:path}",
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
)
|
|
async def passthrough(rest: str, request: Request):
|
|
body = await request.body()
|
|
body = _scrub_max_tokens(body)
|
|
|
|
forward_headers: dict[str, str] = {}
|
|
for k, v in request.headers.items():
|
|
if k.lower() in _HOP_HEADERS:
|
|
continue
|
|
forward_headers[k] = v
|
|
|
|
upstream_url = f"{_OPENAI_UPSTREAM}/{rest}"
|
|
if request.url.query:
|
|
upstream_url = f"{upstream_url}?{request.url.query}"
|
|
|
|
# Stream upstream response body straight back to the caller. httpx's
|
|
# streaming context handles Server-Sent Events the CLI uses for chat
|
|
# completions without buffering the full response in memory.
|
|
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=30.0))
|
|
try:
|
|
upstream_req = client.build_request(
|
|
request.method,
|
|
upstream_url,
|
|
headers=forward_headers,
|
|
content=body,
|
|
)
|
|
upstream_resp = await client.send(upstream_req, stream=True)
|
|
except httpx.HTTPError as e:
|
|
await client.aclose()
|
|
logger.warning("openai-passthrough upstream error: %s", e)
|
|
return JSONResponse(
|
|
{"error": {"message": str(e), "type": "upstream_error"}},
|
|
status_code=502,
|
|
)
|
|
|
|
response_headers: dict[str, str] = {}
|
|
for k, v in upstream_resp.headers.items():
|
|
if k.lower() in _HOP_HEADERS:
|
|
continue
|
|
response_headers[k] = v
|
|
|
|
async def streamer():
|
|
try:
|
|
async for chunk in upstream_resp.aiter_raw():
|
|
yield chunk
|
|
finally:
|
|
await upstream_resp.aclose()
|
|
await client.aclose()
|
|
|
|
return StreamingResponse(
|
|
streamer(),
|
|
status_code=upstream_resp.status_code,
|
|
headers=response_headers,
|
|
)
|