headroom/plugins/opencode/src/transport.test.ts
Rod Boev c19347c310
fix(opencode): preserve custom OpenAI gateway paths (#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 #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
https://github.com/headroomlabs-ai/headroom/pull/1573 by keeping
prefixed OpenAI-compatible traffic on Headroom's stable `/v1/*` surface
while preserving the real upstream path for dedicated-handler
reconstruction.

https://github.com/headroomlabs-ai/headroom/pull/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.
2026-06-30 08:41:42 -07:00

379 lines
14 KiB
TypeScript

import childProcess from "node:child_process";
import http from "node:http";
import http2 from "node:http2";
import https from "node:https";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installHeadroomTransport, uninstallHeadroomTransport } from "./transport.js";
afterEach(() => {
uninstallHeadroomTransport();
vi.restoreAllMocks();
});
type FetchCall = [RequestInfo | URL, RequestInit?];
type SeenRequest = {
method: string | undefined;
url: string | undefined;
headers: http.IncomingHttpHeaders;
body: string;
};
function proxyServer(pathPrefix: string = "/v1"): Promise<{
url: string;
seen: SeenRequest[];
close: () => Promise<void>;
}> {
const seen: SeenRequest[] = [];
const server = http.createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
seen.push({ method: req.method, url: req.url, headers: req.headers, body });
res.writeHead(200, { "content-type": "application/json" });
res.end("{\"ok\":true}");
});
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Expected TCP server address"));
return;
}
resolve({
url: `http://127.0.0.1:${address.port}${pathPrefix}`,
seen,
close: () => new Promise((done) => server.close(() => done())),
});
});
});
}
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"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("https://api.deepseek.com/v1/chat/completions?x=1", {
method: "POST",
headers: { authorization: "Bearer test" },
});
await fetch("https://new-provider.example/base/v1/messages", { method: "POST" });
expect(fetchMock).toHaveBeenNthCalledWith(
1,
new URL("http://127.0.0.1:8787/v1/chat/completions?x=1"),
expect.objectContaining({ method: "POST" }),
);
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-base-url")).toBe(
"https://api.deepseek.com",
);
expect(fetchMock.mock.calls[1][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get("x-headroom-base-url")).toBe(
"https://new-provider.example",
);
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"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("http://127.0.0.1:8787/v1/retrieve");
await fetch("http://localhost:4096/config");
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8787/v1/retrieve");
expect(fetchMock.mock.calls[1][0]).toBe("http://localhost:4096/config");
globalThis.fetch = originalFetch;
});
it("routes external https.request calls through the proxy", async () => {
const proxy = await proxyServer();
installHeadroomTransport({ proxyUrl: proxy.url });
await new Promise<void>((resolve, reject) => {
const req = https.request(
"https://api.anthropic.com/v1/messages?beta=1",
{ method: "POST", headers: { authorization: "Bearer test" } },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{\"model\":\"claude\"}");
});
expect(proxy.seen).toHaveLength(1);
expect(proxy.seen[0]).toMatchObject({ method: "POST", url: "/v1/messages?beta=1" });
expect(proxy.seen[0].headers["x-headroom-base-url"]).toBe("https://api.anthropic.com");
expect(proxy.seen[0].headers.host).toMatch(/^127\.0\.0\.1:/);
expect(proxy.seen[0].body).toBe("{\"model\":\"claude\"}");
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<void>((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<void>((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<void>((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<void>((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" });
expect(() => http2.connect("https://api.openai.com")).toThrow(
/blocked direct HTTP\/2 connection to https:\/\/api\.openai\.com/,
);
});
it("preloads the Headroom shim into child Node processes", () => {
const originalNodeOptions = process.env.NODE_OPTIONS;
const originalProxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
try {
process.env.NODE_OPTIONS = "--trace-warnings";
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
expect(process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
expect(process.env.NODE_OPTIONS).toContain("--trace-warnings");
expect(process.env.NODE_OPTIONS).toContain("--import=file:");
expect(process.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
expect(process.env.NODE_OPTIONS?.match(/hook-shim\/handler\.js/g)).toHaveLength(1);
} finally {
if (originalNodeOptions === undefined) {
delete process.env.NODE_OPTIONS;
} else {
process.env.NODE_OPTIONS = originalNodeOptions;
}
if (originalProxyUrl === undefined) {
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
} else {
process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL = originalProxyUrl;
}
uninstallHeadroomTransport();
}
});
it("injects the Headroom shim into child processes with custom env", () => {
const originalSpawn = childProcess.spawn;
const spawnMock = vi.fn(() => ({
on: vi.fn(),
once: vi.fn(),
emit: vi.fn(),
kill: vi.fn(),
killed: false,
pid: 123,
}));
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
try {
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
childProcess.spawn("node", ["agent.js"], { env: { PATH: "/bin", NODE_OPTIONS: "--trace-warnings" } });
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
expect(options.env.PATH).toBe("/bin");
expect(options.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
expect(options.env.NODE_OPTIONS).toContain("--trace-warnings");
expect(options.env.NODE_OPTIONS).toContain("--import=file:");
expect(options.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
} finally {
uninstallHeadroomTransport();
childProcess.spawn = originalSpawn;
}
});
it("restores patched transports only after the final disposer", () => {
const originalFetch = globalThis.fetch;
const originalHttpRequest = http.request;
const originalHttpsRequest = https.request;
const firstDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
const secondDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8788/v1" });
expect(globalThis.fetch).not.toBe(originalFetch);
expect(http.request).not.toBe(originalHttpRequest);
expect(https.request).not.toBe(originalHttpsRequest);
firstDispose();
expect(globalThis.fetch).not.toBe(originalFetch);
expect(http.request).not.toBe(originalHttpRequest);
secondDispose();
expect(globalThis.fetch).toBe(originalFetch);
expect(http.request).toBe(originalHttpRequest);
expect(https.request).toBe(originalHttpsRequest);
});
});