mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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.
This commit is contained in:
parent
1c0e15243e
commit
c19347c310
4 changed files with 567 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ type SeenRequest = {
|
|||
body: string;
|
||||
};
|
||||
|
||||
function proxyServer(): Promise<{ url: string; seen: SeenRequest[]; close: () => Promise<void> }> {
|
||||
function proxyServer(pathPrefix: string = "/v1"): Promise<{
|
||||
url: string;
|
||||
seen: SeenRequest[];
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
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<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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): URL | undefine
|
|||
}
|
||||
}
|
||||
|
||||
function headersForNodeRequest(options: Record<string, unknown>, upstream: URL): Record<string, string> {
|
||||
function headersForNodeRequest(
|
||||
options: Record<string, unknown>,
|
||||
upstream: URL,
|
||||
originalPath: string | undefined,
|
||||
): Record<string, string> {
|
||||
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<string, string> = {};
|
||||
|
|
@ -279,7 +320,7 @@ function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string,
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const nextUrl = routedUrl(parts.url, proxy);
|
||||
const { url: nextUrl, originalPath } = routedUrlForOpenCode(parts.url, proxy);
|
||||
const {
|
||||
agent: _agent,
|
||||
auth: _auth,
|
||||
|
|
@ -307,7 +348,7 @@ function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string,
|
|||
hostname: nextUrl.hostname,
|
||||
port: nextUrl.port || undefined,
|
||||
path: `${nextUrl.pathname}${nextUrl.search}`,
|
||||
headers: headersForNodeRequest(parts.options, parts.url),
|
||||
headers: headersForNodeRequest(parts.options, parts.url, originalPath),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
281
tests/test_proxy/test_openai_transport_path_prefix.py
Normal file
281
tests/test_proxy/test_openai_transport_path_prefix.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""Tests for OpenAI transport path-prefix reconstruction from upstream hints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from headroom.proxy.server import ProxyConfig, create_app
|
||||
|
||||
_OPENAI_CHAT_PATH = "/v1/chat/completions"
|
||||
_OPENAI_RESPONSES_PATH = "/v1/responses"
|
||||
|
||||
|
||||
def _build_openai_client():
|
||||
config = ProxyConfig(
|
||||
optimize=False,
|
||||
cache_enabled=False,
|
||||
rate_limit_enabled=False,
|
||||
cost_tracking_enabled=False,
|
||||
log_requests=False,
|
||||
ccr_inject_tool=False,
|
||||
ccr_handle_responses=False,
|
||||
ccr_context_tracking=False,
|
||||
image_optimize=False,
|
||||
)
|
||||
|
||||
app = create_app(config)
|
||||
proxy = app.state.proxy
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_retry(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: dict,
|
||||
**_kwargs: object,
|
||||
) -> 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue