diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index caa8f9539..0857b4167 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -70,6 +70,10 @@ _OPENAI_RESPONSES_UNIT_EXECUTOR: ThreadPoolExecutor | None = None _WS_ALLOWED_ORIGINS_ENV = "HEADROOM_WS_ORIGINS" _CORS_ALLOWED_ORIGINS_ENV = "HEADROOM_CORS_ORIGINS" _CODEX_RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite" +_OPENAI_CHAT_COMPLETIONS_PATH = "/chat/completions" +_OPENAI_RESPONSES_PATH = "/responses" +_OPENAI_ORIGINAL_PATH_HEADER = "x-headroom-original-path" +_OPENAI_BASE_URL_HEADER = "x-headroom-base-url" def _header_get(headers: dict[str, str], name: str) -> str | None: @@ -81,6 +85,51 @@ def _header_get(headers: dict[str, str], name: str) -> str | None: return None +def _resolve_openai_handler_path( + request_headers: dict[str, str], + *, + handler_path: str, +) -> str: + raw_path = _header_get(request_headers, _OPENAI_ORIGINAL_PATH_HEADER) + upstream_path = raw_path.strip() if raw_path is not None else None + + default_path = f"/v1{handler_path}" + if upstream_path is None: + return default_path + + if not upstream_path.startswith("/") or upstream_path.startswith("//"): + return default_path + + parsed = urlparse(upstream_path) + if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: + return default_path + + if not parsed.path.endswith(handler_path): + return f"/v1{handler_path}" + + return parsed.path + + +def _resolve_openai_upstream_base(request_headers: dict[str, str]) -> str | None: + raw_base_url = _header_get(request_headers, _OPENAI_BASE_URL_HEADER) + if raw_base_url is None: + return None + + normalized = _normalize_origin(raw_base_url) + if normalized is None: + return None + if urlparse(normalized).scheme not in {"http", "https"}: + return None + return normalized + + +def _append_request_query(url: str, query: str) -> str: + if not query: + return url + separator = "&" if "?" in url else "?" + return f"{url}{separator}{query}" + + def _normalize_origin(origin: str) -> str | None: parsed = urlparse(origin.strip()) if not parsed.scheme or not parsed.hostname: @@ -2486,7 +2535,19 @@ class OpenAIHandlerMixin: ) # Direct OpenAI API (no backend configured) - url = build_copilot_upstream_url(self.OPENAI_API_URL, "/v1/chat/completions") + upstream_base_url = _resolve_openai_upstream_base(request.headers) + handler_path = ( + _resolve_openai_handler_path( + request.headers, handler_path=_OPENAI_CHAT_COMPLETIONS_PATH + ) + if upstream_base_url is not None + else "/v1/chat/completions" + ) + url = build_copilot_upstream_url( + upstream_base_url or self.OPENAI_API_URL, + handler_path, + ) + url = _append_request_query(url, request.url.query) try: if stream: @@ -3245,7 +3306,17 @@ class OpenAIHandlerMixin: if is_chatgpt_auth: url = "https://chatgpt.com/backend-api/codex/responses" else: - url = build_copilot_upstream_url(self.OPENAI_API_URL, "/v1/responses") + upstream_base_url = _resolve_openai_upstream_base(request.headers) + handler_path = ( + _resolve_openai_handler_path(request.headers, handler_path=_OPENAI_RESPONSES_PATH) + if upstream_base_url is not None + else "/v1/responses" + ) + url = build_copilot_upstream_url( + upstream_base_url or self.OPENAI_API_URL, + handler_path, + ) + url = _append_request_query(url, request.url.query) # The standalone Rust proxy has native /v1/responses item handling, # but the default CLI runtime is this Python proxy. Compress the diff --git a/plugins/opencode/src/transport.test.ts b/plugins/opencode/src/transport.test.ts index 709f88a05..2e97e1fe5 100644 --- a/plugins/opencode/src/transport.test.ts +++ b/plugins/opencode/src/transport.test.ts @@ -20,7 +20,11 @@ type SeenRequest = { body: string; }; -function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => Promise }> { +function proxyServer(pathPrefix: string = "/v1"): Promise<{ + url: string; + seen: SeenRequest[]; + close: () => Promise; +}> { const seen: SeenRequest[] = []; const server = http.createServer((req, res) => { let body = ""; @@ -44,7 +48,7 @@ function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => return; } resolve({ - url: `http://127.0.0.1:${address.port}/v1`, + url: `http://127.0.0.1:${address.port}${pathPrefix}`, seen, close: () => new Promise((done) => server.close(() => done())), }); @@ -53,6 +57,54 @@ function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => } describe("Headroom OpenCode transport", () => { + it("routes fetch chat paths through /v1/chat/completions with proxy base and normalized-path header", async () => { + const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"]; + const upstreamPath = "/api/coding/paas/v4/chat/completions"; + for (const proxyUrl of proxyTargets) { + const proxyOrigin = new URL(proxyUrl).origin; + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok")); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + installHeadroomTransport({ proxyUrl }); + + await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/chat/completions`)); + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn"); + expect(headers.get("x-headroom-original-path")).toBe(upstreamPath); + + globalThis.fetch = originalFetch; + uninstallHeadroomTransport(); + } + }); + + it("routes fetch responses paths through /v1/responses with proxy base and normalized-path header", async () => { + const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"]; + const upstreamPath = "/api/coding/paas/v4/responses"; + for (const proxyUrl of proxyTargets) { + const proxyOrigin = new URL(proxyUrl).origin; + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok")); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + installHeadroomTransport({ proxyUrl }); + + await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/responses`)); + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn"); + expect(headers.get("x-headroom-original-path")).toBe(upstreamPath); + + globalThis.fetch = originalFetch; + uninstallHeadroomTransport(); + } + }); + it("routes external fetch calls through the proxy without pre-registering providers", async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok")); @@ -82,6 +134,22 @@ describe("Headroom OpenCode transport", () => { globalThis.fetch = originalFetch; }); + it("preserves non-prefix paths like /base/v1/messages", async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok")); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" }); + + await fetch("https://example.test/base/v1/messages", { method: "POST" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages")); + expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-original-path")).toBeNull(); + + globalThis.fetch = originalFetch; + }); + it("bypasses local, OpenCode, and Headroom proxy fetch URLs", async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok")); @@ -124,6 +192,102 @@ describe("Headroom OpenCode transport", () => { await proxy.close(); }); + it("normalizes Node HTTP(S) requests for /chat/completions and /responses", async () => { + const proxy = await proxyServer(""); + installHeadroomTransport({ proxyUrl: proxy.url }); + const httpChatPath = "/api/coding/paas/v4/chat/completions"; + const httpResponsesPath = "/api/coding/paas/v4/responses"; + const httpsChatPath = "/v4/openai/chat/completions"; + const httpsResponsesPath = "/v4/openai/responses"; + + await new Promise((resolve, reject) => { + const req = http.request( + `http://open.bigmodel.cn${httpChatPath}`, + { method: "POST", headers: { authorization: "Bearer test" } }, + (res) => { + res.resume(); + res.on("end", resolve); + }, + ); + req.on("error", reject); + req.end("{\"model\":\"gpt-4\"}"); + }); + + await new Promise((resolve, reject) => { + const req = http.request( + `http://open.bigmodel.cn${httpResponsesPath}`, + { method: "POST", headers: { authorization: "Bearer test" } }, + (res) => { + res.resume(); + res.on("end", resolve); + }, + ); + req.on("error", reject); + req.end("{\"model\":\"gpt-4\"}"); + }); + + await new Promise((resolve, reject) => { + const req = https.request( + `https://api.deepseek.com${httpsChatPath}`, + { method: "POST", headers: { authorization: "Bearer test" } }, + (res) => { + res.resume(); + res.on("end", resolve); + }, + ); + req.on("error", reject); + req.end("{\"model\":\"gpt-4\"}"); + }); + + await new Promise((resolve, reject) => { + const req = https.request( + `https://api.deepseek.com${httpsResponsesPath}`, + { method: "POST", headers: { authorization: "Bearer test" } }, + (res) => { + res.resume(); + res.on("end", resolve); + }, + ); + req.on("error", reject); + req.end("{\"model\":\"gpt-4\"}"); + }); + + expect(proxy.seen[0]).toMatchObject({ + method: "POST", + url: "/v1/chat/completions", + headers: expect.objectContaining({ + "x-headroom-base-url": "http://open.bigmodel.cn", + "x-headroom-original-path": httpChatPath, + }), + }); + expect(proxy.seen[1]).toMatchObject({ + method: "POST", + url: "/v1/responses", + headers: expect.objectContaining({ + "x-headroom-base-url": "http://open.bigmodel.cn", + "x-headroom-original-path": httpResponsesPath, + }), + }); + expect(proxy.seen[2]).toMatchObject({ + method: "POST", + url: "/v1/chat/completions", + headers: expect.objectContaining({ + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": httpsChatPath, + }), + }); + expect(proxy.seen[3]).toMatchObject({ + method: "POST", + url: "/v1/responses", + headers: expect.objectContaining({ + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": httpsResponsesPath, + }), + }); + + await proxy.close(); + }); + it("blocks external http2 connections instead of leaking them", () => { installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" }); diff --git a/plugins/opencode/src/transport.ts b/plugins/opencode/src/transport.ts index 5ca012cb8..b7ced7912 100644 --- a/plugins/opencode/src/transport.ts +++ b/plugins/opencode/src/transport.ts @@ -7,6 +7,7 @@ const http2 = nodeRequire("node:http2") as typeof import("node:http2"); const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process"); const BASE_URL_HEADER = "x-headroom-base-url"; +const ORIGINAL_PATH_HEADER = "x-headroom-original-path"; const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL"; const STATE_KEY = Symbol.for("headroom.opencode.transport"); @@ -178,6 +179,31 @@ function routedUrl(upstream: URL, proxy: URL): URL { return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin); } +function normalizedOpenAiProxyPath(pathname: string): string | undefined { + if (pathname.endsWith("/chat/completions")) { + return "/v1/chat/completions"; + } + if (pathname.endsWith("/responses")) { + return "/v1/responses"; + } + return undefined; +} + +function routedUrlForOpenCode(upstream: URL, proxy: URL): { url: URL; originalPath: string | undefined } { + const normalizedPath = normalizedOpenAiProxyPath(upstream.pathname); + if (!normalizedPath) { + return { + url: routedUrl(upstream, proxy), + originalPath: undefined, + }; + } + + return { + url: new URL(`${normalizedPath}${upstream.search}`, proxy.origin), + originalPath: upstream.pathname, + }; +} + function requestUrl(input: RequestInfo | URL): URL { if (input instanceof Request) { return new URL(input.url); @@ -188,7 +214,12 @@ function requestUrl(input: RequestInfo | URL): URL { return new URL(String(input)); } -function mergeFetchHeaders(input: RequestInfo | URL, init?: RequestInit, upstream?: URL): Headers { +function mergeFetchHeaders( + input: RequestInfo | URL, + init: RequestInit | undefined, + upstream: URL | undefined, + originalPath: string | undefined = undefined, +): Headers { const headers = new Headers(input instanceof Request ? input.headers : undefined); if (init?.headers) { new Headers(init.headers).forEach((value, key) => headers.set(key, value)); @@ -197,6 +228,9 @@ function mergeFetchHeaders(input: RequestInfo | URL, init?: RequestInit, upstrea headers.set(BASE_URL_HEADER, upstream.origin); headers.delete("host"); } + if (originalPath) { + headers.set(ORIGINAL_PATH_HEADER, originalPath); + } return headers; } @@ -206,11 +240,11 @@ function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | unde return [input, init]; } + const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy); const nextInit = { ...init, - headers: mergeFetchHeaders(input, init, upstream), + headers: mergeFetchHeaders(input, init, upstream, originalPath), }; - const nextUrl = routedUrl(upstream, proxy); if (input instanceof Request) { return [new Request(nextUrl, input), nextInit]; @@ -262,9 +296,16 @@ function urlFromRequestOptions(options: Record): URL | undefine } } -function headersForNodeRequest(options: Record, upstream: URL): Record { +function headersForNodeRequest( + options: Record, + upstream: URL, + originalPath: string | undefined, +): Record { const headers = new Headers(options.headers as HeadersInit | undefined); headers.set(BASE_URL_HEADER, upstream.origin); + if (originalPath) { + headers.set(ORIGINAL_PATH_HEADER, originalPath); + } headers.delete("host"); const result: Record = {}; @@ -279,7 +320,7 @@ function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record httpx.Response: + captured["method"] = method + captured["url"] = url + captured["headers"] = headers + captured["body"] = body + return httpx.Response( + 200, + json={ + "id": "msg_1", + "object": "response", + "usage": { + "input_tokens": 10, + "output_tokens": 2, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "total_tokens": 12, + }, + }, + ) + + proxy._retry_request = _fake_retry + proxy._record_request_outcome = AsyncMock() + + return TestClient(app), captured + + +def _assert_internal_header_absent(captured: dict[str, object], name: str) -> None: + assert isinstance(captured.get("headers"), dict) + headers = {k.lower() for k in captured["headers"].keys()} # type: ignore[union-attr] + assert name.lower() not in headers + + +def _assert_path(captured: dict[str, object], path: str) -> None: + url = captured.get("url") + assert isinstance(url, str) + assert url.endswith(path) + + +def _assert_origin(captured: dict[str, object], origin: str) -> None: + url = captured.get("url") + assert isinstance(url, str) + assert url.startswith(origin) + + +def test_chat_upstream_reconstruction_base_fails_head_passes() -> None: + endpoint = _OPENAI_CHAT_PATH + body = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + base_fail = "://bad-base" + + for headers in [ + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": base_fail, + "x-headroom-original-path": "/chat/completions", + }, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "/chat/completions", + }, + ]: + client, captured = _build_openai_client() + response = client.post(endpoint, headers=headers, json=body) + assert response.status_code == 200, response.text + + assert captured["method"] == "POST" + if headers["x-headroom-base-url"] == base_fail: + _assert_path(captured, "/v1/chat/completions") + else: + _assert_origin(captured, "https://api.deepseek.com") + _assert_path(captured, "/chat/completions") + + +def test_responses_upstream_reconstruction_base_fails_head_passes() -> None: + endpoint = _OPENAI_RESPONSES_PATH + body = {"model": "gpt-4o", "input": "hi"} + base_fail = "://bad-base" + + for headers in [ + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": base_fail, + "x-headroom-original-path": "/responses", + }, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "/responses", + }, + ]: + client, captured = _build_openai_client() + response = client.post(endpoint, headers=headers, json=body) + assert response.status_code == 200, response.text + + assert captured["method"] == "POST" + if headers["x-headroom-base-url"] == base_fail: + _assert_path(captured, "/v1/responses") + else: + _assert_origin(captured, "https://api.deepseek.com") + _assert_path(captured, "/responses") + + +def test_direct_v1_paths_are_preserved() -> None: + cases = [ + ( + _OPENAI_CHAT_PATH, + {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}, + "/v1/chat/completions", + ), + ( + _OPENAI_RESPONSES_PATH, + {"model": "gpt-4o", "input": "hi"}, + "/v1/responses", + ), + ] + + for endpoint, body, expected_path in cases: + client, captured = _build_openai_client() + response = client.post( + endpoint, + headers={"Authorization": "Bearer sk-test"}, + json=body, + ) + assert response.status_code == 200, response.text + assert captured["method"] == "POST" + _assert_path(captured, expected_path) + + +def test_query_string_is_preserved_for_reconstructed_upstream_paths() -> None: + cases = [ + ( + _OPENAI_CHAT_PATH, + {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "/base/chat/completions", + }, + "/base/chat/completions?foo=1", + ), + ( + _OPENAI_RESPONSES_PATH, + {"model": "gpt-4o", "input": "hi"}, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "/base/responses", + }, + "/base/responses?foo=1", + ), + ] + + for endpoint, body, headers, expected_path in cases: + client, captured = _build_openai_client() + response = client.post(f"{endpoint}?foo=1", headers=headers, json=body) + assert response.status_code == 200, response.text + assert captured["method"] == "POST" + _assert_origin(captured, "https://api.deepseek.com") + _assert_path(captured, expected_path) + + +def test_non_http_base_url_falls_back_to_v1() -> None: + cases = [ + ( + _OPENAI_CHAT_PATH, + {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "ws://api.deepseek.com", + "x-headroom-original-path": "/chat/completions", + }, + "/v1/chat/completions", + ), + ( + _OPENAI_RESPONSES_PATH, + {"model": "gpt-4o", "input": "hi"}, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "wss://api.deepseek.com", + "x-headroom-original-path": "/responses", + }, + "/v1/responses", + ), + ] + + for endpoint, body, headers, expected_path in cases: + client, captured = _build_openai_client() + response = client.post(endpoint, headers=headers, json=body) + assert response.status_code == 200, response.text + assert captured["method"] == "POST" + _assert_path(captured, expected_path) + + +def test_invalid_original_path_falls_back_to_v1() -> None: + cases = [ + ( + _OPENAI_CHAT_PATH, + {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "https://evil.example/chat/completions", + }, + "/v1/chat/completions", + ), + ( + _OPENAI_RESPONSES_PATH, + {"model": "gpt-4o", "input": "hi"}, + { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "/x/responses?bad", + }, + "/v1/responses", + ), + ] + + for endpoint, body, headers, expected_path in cases: + client, captured = _build_openai_client() + response = client.post(endpoint, headers=headers, json=body) + assert response.status_code == 200, response.text + assert captured["method"] == "POST" + _assert_path(captured, expected_path) + + +def test_original_path_header_is_not_forwarded_upstream() -> None: + headers = { + "Authorization": "Bearer sk-test", + "x-headroom-base-url": "https://api.deepseek.com", + "x-headroom-original-path": "/chat/completions", + } + body = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + + client, captured = _build_openai_client() + response = client.post(_OPENAI_CHAT_PATH, headers=headers, json=body) + assert response.status_code == 200, response.text + + _assert_internal_header_absent(captured, "x-headroom-original-path") + _assert_internal_header_absent(captured, "x-headroom-base-url")