From 5a554ed6b3ffd7c127e1fb62807af65a2420930d Mon Sep 17 00:00:00 2001 From: Minsecrus <2972853299@qq.com> Date: Tue, 7 Jul 2026 13:35:55 +0800 Subject: [PATCH 1/2] fix: pass through non-fold endpoints instead of 404ing Only POST on the listen paths was routed, so GET /v1/models (and anything else off the fold path) returned 404/405 and agents couldn't refresh their model list. Add a catch-all transparent passthrough: GET /v1/models now forwards to /models, etc. The fold routes stay POST-only and listed first, so POST /v1/responses is unchanged. Reuses the existing passthrough + credential guard; open_passthrough gains a method kwarg (default POST) so the caller's method is forwarded verbatim instead of hardcoded to POST. Fixes #1 Co-Authored-By: Claude Fable 5 --- middleware/app.py | 93 ++++++++++++++++++++++++++++++++++++++-- middleware/proxy.py | 9 +++- tests/test_middleware.py | 79 ++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 5 deletions(-) diff --git a/middleware/app.py b/middleware/app.py index 5c0c3ac..9c4516f 100644 --- a/middleware/app.py +++ b/middleware/app.py @@ -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 '/' + (e.g. '/v1/responses') and the resolved upstream responses URL is '/responses'. + A non-fold request is forwarded to '/', where is the incoming + path with the matched listen-path root removed (GET /v1/models -> /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: @@ -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 / @@ -215,4 +298,8 @@ 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)) return Starlette(routes=routes, lifespan=lifespan) diff --git a/middleware/proxy.py b/middleware/proxy.py index da4b406..24df073 100644 --- a/middleware/proxy.py +++ b/middleware/proxy.py @@ -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) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 216605a..e490e1e 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -441,6 +441,83 @@ 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)) + + # --- security guard: never send config creds to a header-supplied URL -------- @@ -624,6 +701,8 @@ 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() test_auth_safety_guard() test_auth_injection() test_reasoning_gate() From 7ae91185673ab68b8ef7fbf4fd89b1df65e457d5 Mon Sep 17 00:00:00 2001 From: Minsecrus <2972853299@qq.com> Date: Sun, 12 Jul 2026 06:00:51 +0800 Subject: [PATCH 2/2] fix: route all passthrough HTTP methods --- middleware/app.py | 6 +++++- tests/test_middleware.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/middleware/app.py b/middleware/app.py index 9c4516f..6681131 100644 --- a/middleware/app.py +++ b/middleware/app.py @@ -301,5 +301,9 @@ async def lifespan(app: Starlette): # 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)) + routes.append(Route( + "/{path:path}", + handle_passthrough, + methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + )) return Starlette(routes=routes, lifespan=lifespan) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index e490e1e..973a39c 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -518,6 +518,48 @@ async def send(self, req, stream=True): 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 -------- @@ -703,6 +745,7 @@ async def _main(): 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()