Skip to content

Commit c19347c

Browse files
authored
fix(opencode): preserve custom OpenAI gateway paths (headroomlabs-ai#1596)
## Description Custom OpenAI-compatible gateways mounted under provider-specific prefixes could miss Headroom's dedicated OpenAI compression routes when used through the OpenCode transport. A request such as `https://open.bigmodel.cn/api/coding/paas/v4/chat/completions` was replayed to the proxy at `/api/coding/paas/v4/chat/completions`, so the proxy selected catch-all passthrough instead of `/v1/chat/completions`. This change keeps the proxy-facing entrypoints stable on `/v1/chat/completions` and `/v1/responses` for OpenAI-compatible suffixes, while preserving the original upstream path in an internal header so the dedicated OpenAI handlers can reconstruct the real provider URL. Closes headroomlabs-ai#1582 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Normalize opencode-routed OpenAI-compatible `/chat/completions` and `/responses` requests onto the proxy's stable `/v1/*` routes. - Preserve the original upstream pathname in an internal `x-headroom-original-path` signal for dedicated OpenAI handler reconstruction. - Reconstruct dedicated OpenAI upstream URLs from `x-headroom-base-url` plus the preserved path prefix, while preserving request query strings and rejecting non-HTTP base hints. - Keep nearby non-OpenAI paths such as `/base/v1/messages` on existing passthrough behavior. - Add focused transport and proxy regression coverage for prefixed gateway paths, invalid fallback cases, and internal-header stripping. ## Testing - [x] Transport regression tests pass (`npm --prefix plugins/opencode test -- src/transport.test.ts`) - [x] Proxy regression tests pass (`uv run pytest tests/test_proxy/test_openai_transport_path_prefix.py -q`) - [x] Linting passes (`uv run ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_transport_path_prefix.py`) - [x] Type checking passes (`npm --prefix plugins/opencode run typecheck`) - [x] New tests added for the bugfix - [ ] Manual testing performed ### Test Output ```text npm --prefix plugins/opencode test -- src/transport.test.ts PASS, 11 tests passed. npm --prefix plugins/opencode run typecheck PASS uv run pytest tests/test_proxy/test_openai_transport_path_prefix.py -q PASS, 7 tests passed. uv run ruff check headroom/proxy/handlers/openai.py tests/test_proxy/test_openai_transport_path_prefix.py PASS, all checks passed. ``` ## Real Behavior Proof - Environment: Windows, Python 3.12 via `uv`, Node 18+, focused OpenCode transport and proxy handler tests. - Exact command / steps: on `origin/main`, copy the updated `plugins/opencode/src/transport.test.ts` into a base worktree and run `npm --prefix plugins/opencode test -- src/transport.test.ts`; on this branch, rerun that transport test plus `npm --prefix plugins/opencode run typecheck` and `uv run pytest tests/test_proxy/test_openai_transport_path_prefix.py -q`. - Observed result: the base worktree fails because prefixed `/chat/completions` and `/responses` requests still enter the proxy at their provider path, while this branch passes with `/v1/chat/completions` and `/v1/responses`, preserves `x-headroom-original-path`, reconstructs the provider-prefixed upstream URL and query string, falls back safely on invalid hints, and keeps nearby `/base/v1/messages` traffic on passthrough. - Not tested: full CI suite, live BigModel traffic, and generic catch-all passthrough compression. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes This completes the transport contract introduced in headroomlabs-ai#1573 by keeping prefixed OpenAI-compatible traffic on Headroom's stable `/v1/*` surface while preserving the real upstream path for dedicated-handler reconstruction. headroomlabs-ai#1367 is adjacent global proxy configuration work for direct deployments; this PR is the per-request OpenCode transport fix for custom upstream path prefixes. `CHANGELOG.md` is intentionally unchanged because this repo's release pipeline generates changelog entries from conventional commits. This stays scoped to `/chat/completions` and `/responses` suffixes. Generic catch-all passthrough compression remains separate from this bugfix slice.
1 parent 1c0e152 commit c19347c

4 files changed

Lines changed: 567 additions & 10 deletions

File tree

headroom/proxy/handlers/openai.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@
7070
_WS_ALLOWED_ORIGINS_ENV = "HEADROOM_WS_ORIGINS"
7171
_CORS_ALLOWED_ORIGINS_ENV = "HEADROOM_CORS_ORIGINS"
7272
_CODEX_RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite"
73+
_OPENAI_CHAT_COMPLETIONS_PATH = "/chat/completions"
74+
_OPENAI_RESPONSES_PATH = "/responses"
75+
_OPENAI_ORIGINAL_PATH_HEADER = "x-headroom-original-path"
76+
_OPENAI_BASE_URL_HEADER = "x-headroom-base-url"
7377

7478

7579
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:
8185
return None
8286

8387

88+
def _resolve_openai_handler_path(
89+
request_headers: dict[str, str],
90+
*,
91+
handler_path: str,
92+
) -> str:
93+
raw_path = _header_get(request_headers, _OPENAI_ORIGINAL_PATH_HEADER)
94+
upstream_path = raw_path.strip() if raw_path is not None else None
95+
96+
default_path = f"/v1{handler_path}"
97+
if upstream_path is None:
98+
return default_path
99+
100+
if not upstream_path.startswith("/") or upstream_path.startswith("//"):
101+
return default_path
102+
103+
parsed = urlparse(upstream_path)
104+
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
105+
return default_path
106+
107+
if not parsed.path.endswith(handler_path):
108+
return f"/v1{handler_path}"
109+
110+
return parsed.path
111+
112+
113+
def _resolve_openai_upstream_base(request_headers: dict[str, str]) -> str | None:
114+
raw_base_url = _header_get(request_headers, _OPENAI_BASE_URL_HEADER)
115+
if raw_base_url is None:
116+
return None
117+
118+
normalized = _normalize_origin(raw_base_url)
119+
if normalized is None:
120+
return None
121+
if urlparse(normalized).scheme not in {"http", "https"}:
122+
return None
123+
return normalized
124+
125+
126+
def _append_request_query(url: str, query: str) -> str:
127+
if not query:
128+
return url
129+
separator = "&" if "?" in url else "?"
130+
return f"{url}{separator}{query}"
131+
132+
84133
def _normalize_origin(origin: str) -> str | None:
85134
parsed = urlparse(origin.strip())
86135
if not parsed.scheme or not parsed.hostname:
@@ -2486,7 +2535,19 @@ async def api_call_fn(
24862535
)
24872536

24882537
# Direct OpenAI API (no backend configured)
2489-
url = build_copilot_upstream_url(self.OPENAI_API_URL, "/v1/chat/completions")
2538+
upstream_base_url = _resolve_openai_upstream_base(request.headers)
2539+
handler_path = (
2540+
_resolve_openai_handler_path(
2541+
request.headers, handler_path=_OPENAI_CHAT_COMPLETIONS_PATH
2542+
)
2543+
if upstream_base_url is not None
2544+
else "/v1/chat/completions"
2545+
)
2546+
url = build_copilot_upstream_url(
2547+
upstream_base_url or self.OPENAI_API_URL,
2548+
handler_path,
2549+
)
2550+
url = _append_request_query(url, request.url.query)
24902551

24912552
try:
24922553
if stream:
@@ -3245,7 +3306,17 @@ async def handle_openai_responses(
32453306
if is_chatgpt_auth:
32463307
url = "https://chatgpt.com/backend-api/codex/responses"
32473308
else:
3248-
url = build_copilot_upstream_url(self.OPENAI_API_URL, "/v1/responses")
3309+
upstream_base_url = _resolve_openai_upstream_base(request.headers)
3310+
handler_path = (
3311+
_resolve_openai_handler_path(request.headers, handler_path=_OPENAI_RESPONSES_PATH)
3312+
if upstream_base_url is not None
3313+
else "/v1/responses"
3314+
)
3315+
url = build_copilot_upstream_url(
3316+
upstream_base_url or self.OPENAI_API_URL,
3317+
handler_path,
3318+
)
3319+
url = _append_request_query(url, request.url.query)
32493320

32503321
# The standalone Rust proxy has native /v1/responses item handling,
32513322
# but the default CLI runtime is this Python proxy. Compress the

plugins/opencode/src/transport.test.ts

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ type SeenRequest = {
2020
body: string;
2121
};
2222

23-
function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => Promise<void> }> {
23+
function proxyServer(pathPrefix: string = "/v1"): Promise<{
24+
url: string;
25+
seen: SeenRequest[];
26+
close: () => Promise<void>;
27+
}> {
2428
const seen: SeenRequest[] = [];
2529
const server = http.createServer((req, res) => {
2630
let body = "";
@@ -44,7 +48,7 @@ function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () =>
4448
return;
4549
}
4650
resolve({
47-
url: `http://127.0.0.1:${address.port}/v1`,
51+
url: `http://127.0.0.1:${address.port}${pathPrefix}`,
4852
seen,
4953
close: () => new Promise((done) => server.close(() => done())),
5054
});
@@ -53,6 +57,54 @@ function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () =>
5357
}
5458

5559
describe("Headroom OpenCode transport", () => {
60+
it("routes fetch chat paths through /v1/chat/completions with proxy base and normalized-path header", async () => {
61+
const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"];
62+
const upstreamPath = "/api/coding/paas/v4/chat/completions";
63+
for (const proxyUrl of proxyTargets) {
64+
const proxyOrigin = new URL(proxyUrl).origin;
65+
const originalFetch = globalThis.fetch;
66+
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
67+
globalThis.fetch = fetchMock as unknown as typeof fetch;
68+
69+
installHeadroomTransport({ proxyUrl });
70+
71+
await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } });
72+
73+
expect(fetchMock).toHaveBeenCalledTimes(1);
74+
expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/chat/completions`));
75+
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
76+
expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn");
77+
expect(headers.get("x-headroom-original-path")).toBe(upstreamPath);
78+
79+
globalThis.fetch = originalFetch;
80+
uninstallHeadroomTransport();
81+
}
82+
});
83+
84+
it("routes fetch responses paths through /v1/responses with proxy base and normalized-path header", async () => {
85+
const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"];
86+
const upstreamPath = "/api/coding/paas/v4/responses";
87+
for (const proxyUrl of proxyTargets) {
88+
const proxyOrigin = new URL(proxyUrl).origin;
89+
const originalFetch = globalThis.fetch;
90+
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
91+
globalThis.fetch = fetchMock as unknown as typeof fetch;
92+
93+
installHeadroomTransport({ proxyUrl });
94+
95+
await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } });
96+
97+
expect(fetchMock).toHaveBeenCalledTimes(1);
98+
expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/responses`));
99+
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
100+
expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn");
101+
expect(headers.get("x-headroom-original-path")).toBe(upstreamPath);
102+
103+
globalThis.fetch = originalFetch;
104+
uninstallHeadroomTransport();
105+
}
106+
});
107+
56108
it("routes external fetch calls through the proxy without pre-registering providers", async () => {
57109
const originalFetch = globalThis.fetch;
58110
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
@@ -82,6 +134,22 @@ describe("Headroom OpenCode transport", () => {
82134
globalThis.fetch = originalFetch;
83135
});
84136

137+
it("preserves non-prefix paths like /base/v1/messages", async () => {
138+
const originalFetch = globalThis.fetch;
139+
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
140+
globalThis.fetch = fetchMock as unknown as typeof fetch;
141+
142+
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
143+
144+
await fetch("https://example.test/base/v1/messages", { method: "POST" });
145+
146+
expect(fetchMock).toHaveBeenCalledTimes(1);
147+
expect(fetchMock.mock.calls[0][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
148+
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-original-path")).toBeNull();
149+
150+
globalThis.fetch = originalFetch;
151+
});
152+
85153
it("bypasses local, OpenCode, and Headroom proxy fetch URLs", async () => {
86154
const originalFetch = globalThis.fetch;
87155
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
@@ -124,6 +192,102 @@ describe("Headroom OpenCode transport", () => {
124192
await proxy.close();
125193
});
126194

195+
it("normalizes Node HTTP(S) requests for /chat/completions and /responses", async () => {
196+
const proxy = await proxyServer("");
197+
installHeadroomTransport({ proxyUrl: proxy.url });
198+
const httpChatPath = "/api/coding/paas/v4/chat/completions";
199+
const httpResponsesPath = "/api/coding/paas/v4/responses";
200+
const httpsChatPath = "/v4/openai/chat/completions";
201+
const httpsResponsesPath = "/v4/openai/responses";
202+
203+
await new Promise<void>((resolve, reject) => {
204+
const req = http.request(
205+
`http://open.bigmodel.cn${httpChatPath}`,
206+
{ method: "POST", headers: { authorization: "Bearer test" } },
207+
(res) => {
208+
res.resume();
209+
res.on("end", resolve);
210+
},
211+
);
212+
req.on("error", reject);
213+
req.end("{\"model\":\"gpt-4\"}");
214+
});
215+
216+
await new Promise<void>((resolve, reject) => {
217+
const req = http.request(
218+
`http://open.bigmodel.cn${httpResponsesPath}`,
219+
{ method: "POST", headers: { authorization: "Bearer test" } },
220+
(res) => {
221+
res.resume();
222+
res.on("end", resolve);
223+
},
224+
);
225+
req.on("error", reject);
226+
req.end("{\"model\":\"gpt-4\"}");
227+
});
228+
229+
await new Promise<void>((resolve, reject) => {
230+
const req = https.request(
231+
`https://api.deepseek.com${httpsChatPath}`,
232+
{ method: "POST", headers: { authorization: "Bearer test" } },
233+
(res) => {
234+
res.resume();
235+
res.on("end", resolve);
236+
},
237+
);
238+
req.on("error", reject);
239+
req.end("{\"model\":\"gpt-4\"}");
240+
});
241+
242+
await new Promise<void>((resolve, reject) => {
243+
const req = https.request(
244+
`https://api.deepseek.com${httpsResponsesPath}`,
245+
{ method: "POST", headers: { authorization: "Bearer test" } },
246+
(res) => {
247+
res.resume();
248+
res.on("end", resolve);
249+
},
250+
);
251+
req.on("error", reject);
252+
req.end("{\"model\":\"gpt-4\"}");
253+
});
254+
255+
expect(proxy.seen[0]).toMatchObject({
256+
method: "POST",
257+
url: "/v1/chat/completions",
258+
headers: expect.objectContaining({
259+
"x-headroom-base-url": "http://open.bigmodel.cn",
260+
"x-headroom-original-path": httpChatPath,
261+
}),
262+
});
263+
expect(proxy.seen[1]).toMatchObject({
264+
method: "POST",
265+
url: "/v1/responses",
266+
headers: expect.objectContaining({
267+
"x-headroom-base-url": "http://open.bigmodel.cn",
268+
"x-headroom-original-path": httpResponsesPath,
269+
}),
270+
});
271+
expect(proxy.seen[2]).toMatchObject({
272+
method: "POST",
273+
url: "/v1/chat/completions",
274+
headers: expect.objectContaining({
275+
"x-headroom-base-url": "https://api.deepseek.com",
276+
"x-headroom-original-path": httpsChatPath,
277+
}),
278+
});
279+
expect(proxy.seen[3]).toMatchObject({
280+
method: "POST",
281+
url: "/v1/responses",
282+
headers: expect.objectContaining({
283+
"x-headroom-base-url": "https://api.deepseek.com",
284+
"x-headroom-original-path": httpsResponsesPath,
285+
}),
286+
});
287+
288+
await proxy.close();
289+
});
290+
127291
it("blocks external http2 connections instead of leaking them", () => {
128292
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
129293

0 commit comments

Comments
 (0)