Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions middleware/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,61 @@ def _url_is_from_header(cfg: Config, request: Request) -> bool:
return cfg.upstream.mode in ("header", "header_required") and _header_base(request) is not None


def _strip_responses_suffix(url: str) -> str:
"""Upstream API base: the responses URL with its trailing '/responses' endpoint
removed. 'https://h/v1/responses' -> 'https://h/v1'. A URL not ending in
'/responses' is returned unchanged (treated as an already-base URL)."""
return url[: -len("/responses")] if url.endswith("/responses") else url


def _api_root(listen_path: str) -> str:
"""The directory containing a listen_path's endpoint: '/v1/responses' -> '/v1'."""
return listen_path.rsplit("/", 1)[0] if "/" in listen_path else ""


def _resolve_passthrough_url(cfg: Config, request: Request) -> str | None:
"""Map an arbitrary incoming request to its upstream equivalent for transparent
passthrough (everything that is NOT a fold endpoint, e.g. GET /v1/models).

The proxy serves an OpenAI-style tree: each listen_path is '<root>/<endpoint>'
(e.g. '/v1/responses') and the resolved upstream responses URL is '<base>/responses'.
A non-fold request is forwarded to '<base>/<suffix>', where <suffix> is the incoming
path with the matched listen-path root removed (GET /v1/models -> <base>/models),
so an agent can still list models / hit other endpoints through the proxy. Query
string is preserved. Returns None only under header_required without the header.
"""
responses_url = _resolve_upstream_url(cfg, request)
if responses_url is None:
return None
base = _strip_responses_suffix(responses_url)

path = request.url.path
# Longest matching listen-path root wins (handles multiple listen_paths).
roots = sorted(
{_api_root(p) for p in cfg.server.listen_paths if "/" in p},
key=len,
reverse=True,
)
suffix = path
for root in roots:
if root and (path == root or path.startswith(root + "/")):
suffix = path[len(root):] or "/"
break

target = base.rstrip("/") + suffix
if request.url.query:
target += "?" + request.url.query
return target


async def _passthrough(
client: httpx.AsyncClient, cfg: Config, request: Request, raw: bytes, url: str
client: httpx.AsyncClient, cfg: Config, request: Request, raw: bytes, url: str,
method: str = "POST",
):
"""Pure proxy: forward the raw request and stream the raw response back."""
"""Pure proxy: forward the raw request and stream the raw response back.
`method` defaults to POST (the Responses endpoint); other endpoints pass theirs."""
headers = build_upstream_headers(request.headers.items(), cfg)
resp = await open_passthrough(client, url, raw, headers)
resp = await open_passthrough(client, url, raw, headers, method=method)

async def body_iter():
try:
Expand Down Expand Up @@ -190,6 +239,40 @@ async def handle_responses(request: Request) -> Response:
)


async def handle_passthrough(request: Request) -> Response:
"""Transparent passthrough for any request that is NOT a fold endpoint (e.g.
GET /v1/models, POST /v1/embeddings), so an agent can still list models and hit
other endpoints through the proxy instead of getting 404. The raw request is
forwarded to the upstream equivalent and the response streamed back unchanged.
The same credential-leak guard as the fold path applies (never inject configured
credentials toward a request-supplied Responses-API-Base URL)."""
cfg: Config = request.app.state.cfg
client: httpx.AsyncClient = request.app.state.client

url = _resolve_passthrough_url(cfg, request)
if url is None:
return JSONResponse(
{"error": "Responses-API-Base header is required (upstream mode=header_required)"},
status_code=400,
)

if _url_is_from_header(cfg, request) and would_inject_authorization(
cfg, agent_has_authorization=request.headers.get("authorization") is not None
):
log.warning("blocked passthrough: Responses-API-Base override without own auth (path=%s)",
request.url.path)
return JSONResponse(
{"error": "When overriding the upstream base (Responses-API-Base), the request must "
"provide its own Authorization; the proxy will not send its configured "
"credentials to an externally supplied URL."},
status_code=400,
)

raw = await request.body()
log.info("passthrough: %s %s -> %s", request.method, request.url.path, url)
return await _passthrough(client, cfg, request, raw, url, method=request.method)


def _make_client() -> httpx.AsyncClient:
"""A client that does NOT invent a User-Agent or Accept of its own; those
are forwarded from the agent or omitted. httpx still manages Host /
Expand All @@ -215,4 +298,12 @@ async def lifespan(app: Starlette):
routes = [
Route(path, handle_responses, methods=["POST"]) for path in cfg.server.listen_paths
]
# Catch-all transparent passthrough for everything else (GET /v1/models, etc.).
# Fold routes are POST-only and listed first, so they win on full match; any other
# path/method falls through here and is forwarded to the upstream unchanged.
routes.append(Route(
"/{path:path}",
handle_passthrough,
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
))
return Starlette(routes=routes, lifespan=lifespan)
9 changes: 7 additions & 2 deletions middleware/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ async def open_passthrough(
url: str,
raw_body: bytes,
headers: dict[str, str],
method: str = "POST",
) -> httpx.Response:
"""Open a streaming upstream request forwarding the raw body unchanged."""
req = client.build_request("POST", url, content=raw_body, headers=headers, timeout=None)
"""Open a streaming upstream request forwarding the raw body unchanged.

`method` defaults to POST (the Responses endpoint). Transparent passthrough of
other endpoints (e.g. GET /v1/models) passes the caller's method through verbatim.
"""
req = client.build_request(method, url, content=raw_body, headers=headers, timeout=None)
return await client.send(req, stream=True)


Expand Down
122 changes: 122 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,125 @@ def test_upstream_url_resolution():
_resolve_upstream_url(req, _Req({"Responses-API-Base": " "})) is None)


# --- transparent passthrough URL mapping (models + other endpoints) ----------


class _PURL:
def __init__(self, path: str, query: str = ""):
self.path = path
self.query = query


class _PReq:
"""Minimal request fake for passthrough URL resolution (path + query + headers)."""

def __init__(self, path: str, headers: dict | None = None, query: str = ""):
self.url = _PURL(path, query)
self.headers = Headers(headers or {})


def test_passthrough_url_resolution():
from middleware.app import _resolve_passthrough_url

base = load_config(ROOT / "config.toml")
base = replace(base, server=replace(base.server, listen_paths=("/v1/responses",)))

fixed = replace(base, upstream=replace(base.upstream, mode="fixed",
url="https://h/v1/responses"))
check("pt /v1/models -> base/models",
_resolve_passthrough_url(fixed, _PReq("/v1/models")) == "https://h/v1/models")
check("pt /v1/embeddings -> base/embeddings",
_resolve_passthrough_url(fixed, _PReq("/v1/embeddings")) == "https://h/v1/embeddings")
check("pt /v1/responses maps back to responses endpoint",
_resolve_passthrough_url(fixed, _PReq("/v1/responses")) == "https://h/v1/responses")
check("pt preserves query string",
_resolve_passthrough_url(fixed, _PReq("/v1/models", query="limit=10"))
== "https://h/v1/models?limit=10")

# ChatGPT-style URL: strip '/responses' -> base path is /backend-api/codex.
cg = replace(base, upstream=replace(base.upstream, mode="fixed",
url="https://chatgpt.com/backend-api/codex/responses"))
check("pt chatgpt base mapping",
_resolve_passthrough_url(cg, _PReq("/v1/models"))
== "https://chatgpt.com/backend-api/codex/models")

# header mode: base derived from the Responses-API-Base header.
header = replace(base, upstream=replace(base.upstream, mode="header",
url="https://cfg/responses"))
check("pt header base mapping",
_resolve_passthrough_url(header, _PReq("/v1/models",
headers={"Responses-API-Base": "https://ov/v1"})) == "https://ov/v1/models")

# header_required without header -> None (caller returns 400).
req = replace(base, upstream=replace(base.upstream, mode="header_required",
url="https://cfg/responses"))
check("pt header_required no header -> None",
_resolve_passthrough_url(req, _PReq("/v1/models")) is None)


async def test_passthrough_preserves_method():
from middleware.proxy import open_passthrough
seen: dict = {}

class _CapClient:
def build_request(self, *a, **k):
seen["method"] = a[0] if a else k.get("method")
return ("req", a, k)

async def send(self, req, stream=True):
return FakeResp(b'{"data": []}', status=200)

await open_passthrough(_CapClient(), "https://h/v1/models", b"", {}, method="GET")
check("passthrough forwards GET method", seen.get("method") == "GET", str(seen))
await open_passthrough(_CapClient(), "https://h/v1/responses", b"{}", {}, method="POST")
check("passthrough forwards POST method", seen.get("method") == "POST")
seen.clear()
await open_passthrough(_CapClient(), "https://h/v1/models", b"", {}) # default
check("passthrough defaults to POST", seen.get("method") == "POST", str(seen))


async def test_passthrough_routing():
"""Exercise Starlette's router, not just the passthrough helper functions."""
import httpx
import middleware.app as app_module
from starlette.responses import PlainTextResponse

async def fold_marker(request):
return PlainTextResponse(f"FOLD {request.method}")

async def passthrough_marker(request):
return PlainTextResponse(f"PASS {request.method}")

# create_app captures the handlers in Route objects, so restoring the module
# globals immediately afterward keeps this test isolated from the rest.
original_fold = app_module.handle_responses
original_passthrough = app_module.handle_passthrough
try:
app_module.handle_responses = fold_marker
app_module.handle_passthrough = passthrough_marker
app = app_module.create_app(load_config(ROOT / "config.toml"))
finally:
app_module.handle_responses = original_fold
app_module.handle_passthrough = original_passthrough

transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for method in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"):
response = await client.request(method, "/v1/models")
check(f"route passes {method}", response.status_code == 200,
f"status={response.status_code}")
if method != "HEAD":
check(f"route dispatches {method} to passthrough",
response.text == f"PASS {method}", response.text)

response = await client.post("/v1/responses")
check("route keeps POST /v1/responses on fold handler",
response.text == "FOLD POST", response.text)
response = await client.get("/v1/responses")
check("route sends GET /v1/responses to passthrough",
response.text == "PASS GET", response.text)


# --- security guard: never send config creds to a header-supplied URL --------


Expand Down Expand Up @@ -624,6 +743,9 @@ async def _main():
await test_forward_marker_emits_downstream()
test_header_transparency()
test_upstream_url_resolution()
test_passthrough_url_resolution()
await test_passthrough_preserves_method()
await test_passthrough_routing()
test_auth_safety_guard()
test_auth_injection()
test_reasoning_gate()
Expand Down