mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
"""Tiny OpenAI passthrough renaming max_tokens to max_completion_tokens for GPT-5; 9Router 0.3.60 is pinned and doesn't know the change."""
|
|
|
|
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)
|
|
|
|
|
|
# Mirrors anthropic_proxy.py's GPT-5 matcher; duplicated to avoid the cross-module dep.
|
|
P_GPT5_PREFIXES = ("gpt-5",)
|
|
P_OPENAI_UPSTREAM = "https://api.openai.com/v1"
|
|
P_HOP_HEADERS = {
|
|
"host", "content-length", "connection", "keep-alive",
|
|
"proxy-authenticate", "proxy-authorization", "te", "trailers",
|
|
"transfer-encoding", "upgrade",
|
|
}
|
|
|
|
|
|
def p_is_gpt5(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
if not m:
|
|
return False
|
|
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 P_GPT5_PREFIXES)
|
|
|
|
|
|
# GPT-5 reasoning models reject sampling knobs: temperature must be the default (only 1 is allowed), and top_p / penalties / logprobs are unsupported outright. 9Router 0.3.60 is pinned and forwards whatever the user's picked model carried, so we strip them at this last hop before OpenAI or the whole request 400s.
|
|
P_GPT5_UNSUPPORTED_PARAMS = (
|
|
"top_p", "top_k", "frequency_penalty", "presence_penalty",
|
|
"logprobs", "top_logprobs", "logit_bias",
|
|
)
|
|
|
|
|
|
def scrub_gpt5_params(body: bytes) -> bytes:
|
|
"""For GPT-5: rename max_tokens→max_completion_tokens and drop the sampling
|
|
params the reasoning models reject. Bytes in/out, never raises."""
|
|
if not body:
|
|
return body
|
|
try:
|
|
parsed = json.loads(body)
|
|
except Exception:
|
|
return body
|
|
if not isinstance(parsed, dict) or not p_is_gpt5(str(parsed.get("model") or "")):
|
|
return body
|
|
mutated = False
|
|
if "max_tokens" in parsed:
|
|
if "max_completion_tokens" not in parsed:
|
|
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
|
|
else:
|
|
parsed.pop("max_tokens", None)
|
|
mutated = True
|
|
if "temperature" in parsed and parsed["temperature"] != 1:
|
|
parsed.pop("temperature", None)
|
|
mutated = True
|
|
for k in P_GPT5_UNSUPPORTED_PARAMS:
|
|
if parsed.pop(k, None) is not None:
|
|
mutated = True
|
|
return json.dumps(parsed).encode("utf-8") if mutated else 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_gpt5_params(body)
|
|
|
|
forward_headers: dict[str, str] = {}
|
|
for k, v in request.headers.items():
|
|
if k.lower() in P_HOP_HEADERS:
|
|
continue
|
|
forward_headers[k] = v
|
|
|
|
upstream_url = f"{P_OPENAI_UPSTREAM}/{rest}"
|
|
if request.url.query:
|
|
upstream_url = f"{upstream_url}?{request.url.query}"
|
|
|
|
# Stream upstream body back; httpx handles SSE without buffering the full response.
|
|
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 P_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,
|
|
)
|