Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions src/hayhooks/server/routers/openai.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import inspect
import time
from collections.abc import AsyncGenerator, Generator
from dataclasses import dataclass
Expand Down Expand Up @@ -104,6 +105,29 @@ def _select_execution_mode(wrapper: BasePipelineWrapper, dispatch: _OpenAIDispat
raise HTTPException(status_code=501, detail=dispatch.not_implemented_detail)


def _method_accepts_kwarg(method: Any, name: str) -> bool:
"""True if `method` declares keyword argument `name` (explicitly or via **kwargs)."""
try:
params = inspect.signature(method).parameters
except (TypeError, ValueError):
return False
return name in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())


def _build_call_kwargs(
wrapper: BasePipelineWrapper,
method_name: str,
base_kwargs: dict[str, Any],
body: dict[str, Any],
headers: dict[str, str] | None,
) -> dict[str, Any]:
"""Assemble the wrapper-call kwargs, forwarding `headers` only if the wrapper method opts in."""
call_kwargs = {**base_kwargs, "body": body}
if headers is not None and _method_accepts_kwarg(getattr(wrapper, method_name), "headers"):
call_kwargs["headers"] = headers
return call_kwargs


async def _invoke_pipeline_method(
wrapper: BasePipelineWrapper, *, mode: str, method_name: str, model: str, call_kwargs: dict[str, Any]
) -> Any:
Expand Down Expand Up @@ -140,6 +164,7 @@ async def _run_pipeline_method(
model: str,
kwargs: dict[str, Any],
body: dict[str, Any],
headers: dict[str, str] | None = None,
) -> str | Generator | AsyncGenerator:
"""Shared dispatch logic for chat completions and responses endpoints."""
stream_requested = bool(body.get("stream", False))
Expand All @@ -155,8 +180,9 @@ async def _run_pipeline_method(
try:
wrapper = _resolve_pipeline_wrapper(model)
mode, method_name = _select_execution_mode(wrapper, dispatch)
call_kwargs = _build_call_kwargs(wrapper, method_name, kwargs, body, headers)
result = await _invoke_pipeline_method(
wrapper, mode=mode, method_name=method_name, model=model, call_kwargs={**kwargs, "body": body}
wrapper, mode=mode, method_name=method_name, model=model, call_kwargs=call_kwargs
)
normalized_result = await _normalize_result(result, stream_requested=stream_requested)
except BaseException:
Expand All @@ -175,22 +201,27 @@ async def _run_pipeline_method(
wrapper = _resolve_pipeline_wrapper(model)
mode, method_name = _select_execution_mode(wrapper, dispatch)
span.set_tag("hayhooks.openai.execution_mode", mode)
call_kwargs = _build_call_kwargs(wrapper, method_name, kwargs, body, headers)
result = await _invoke_pipeline_method(
wrapper, mode=mode, method_name=method_name, model=model, call_kwargs={**kwargs, "body": body}
wrapper, mode=mode, method_name=method_name, model=model, call_kwargs=call_kwargs
)
return await _normalize_result(result, stream_requested=stream_requested)


async def _run_completion(
model: str, messages: list[dict[str, Any]], body: dict[str, Any]
model: str, messages: list[dict[str, Any]], body: dict[str, Any], headers: dict[str, str] | None = None
) -> str | Generator | AsyncGenerator:
return await _run_pipeline_method(_CHAT_COMPLETION_DISPATCH, model=model, kwargs={"messages": messages}, body=body)
return await _run_pipeline_method(
_CHAT_COMPLETION_DISPATCH, model=model, kwargs={"messages": messages}, body=body, headers=headers
)


async def _run_response(
model: str, input_items: list[dict[str, Any]], body: dict[str, Any]
model: str, input_items: list[dict[str, Any]], body: dict[str, Any], headers: dict[str, str] | None = None
) -> str | Generator | AsyncGenerator:
return await _run_pipeline_method(_RESPONSE_DISPATCH, model=model, kwargs={"input_items": input_items}, body=body)
return await _run_pipeline_method(
_RESPONSE_DISPATCH, model=model, kwargs={"input_items": input_items}, body=body, headers=headers
)


def _find_file_upload_wrapper() -> BasePipelineWrapper | None:
Expand Down
14 changes: 14 additions & 0 deletions src/hayhooks/server/utils/base_pipeline_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,20 @@ def run_chat_completion(self, model: str, messages: list[dict], body: dict) -> s
model: The `name` of the deployed Haystack pipeline to run
messages: The history of messages as OpenAI-compatible list of dicts
body: Additional parameters and configuration options

Optionally, override this method with an extra ``headers: dict[str, str]`` parameter to
receive the incoming request headers (e.g. ``Authorization``). Headers are only passed when
the overriding method declares the parameter, so existing implementations are unaffected.
"""
msg = "run_chat_completion not implemented"
raise NotImplementedError(msg)

async def run_chat_completion_async(self, model: str, messages: list[dict], body: dict) -> str | AsyncGenerator:
"""
Asynchronous version of run_chat_completion.

Accepts an optional ``headers: dict[str, str]`` parameter on the same opt-in basis as
run_chat_completion.
"""
msg = "run_chat_completion_async not implemented"
raise NotImplementedError(msg)
Expand All @@ -99,13 +106,20 @@ def run_response(self, model: str, input_items: list[dict], body: dict) -> str |
model: The `name` of the deployed Haystack pipeline to run
input_items: Normalized input items in OpenAI Responses API format
body: Additional parameters and configuration options (e.g. temperature, tools, instructions)

Optionally, override this method with an extra ``headers: dict[str, str]`` parameter to
receive the incoming request headers (e.g. ``Authorization``). Headers are only passed when
the overriding method declares the parameter, so existing implementations are unaffected.
"""
msg = "run_response not implemented"
raise NotImplementedError(msg)

async def run_response_async(self, model: str, input_items: list[dict], body: dict) -> str | AsyncGenerator:
"""
Asynchronous version of run_response.

Accepts an optional ``headers: dict[str, str]`` parameter on the same opt-in basis as
run_response.
"""
msg = "run_response_async not implemented"
raise NotImplementedError(msg)
Expand Down
14 changes: 14 additions & 0 deletions tests/test_files/files/chat_with_headers/pipeline_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from haystack import Pipeline

from hayhooks import BasePipelineWrapper


class PipelineWrapper(BasePipelineWrapper):
"""Declares the optional `headers` parameter, so Hayhooks forwards the request headers."""

def setup(self) -> None:
self.pipeline = Pipeline()

def run_chat_completion(self, model: str, messages: list[dict], body: dict, headers: dict[str, str]) -> str:
# NOTE: This is used in tests, please don't change it
return f"authorization={headers.get('authorization', 'missing')}"
14 changes: 14 additions & 0 deletions tests/test_files/files/chat_without_headers/pipeline_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from haystack import Pipeline

from hayhooks import BasePipelineWrapper


class PipelineWrapper(BasePipelineWrapper):
"""Keeps the pre-existing signature, so Hayhooks must not forward request headers."""

def setup(self) -> None:
self.pipeline = Pipeline()

def run_chat_completion(self, model: str, messages: list[dict], body: dict) -> str:
# NOTE: This is used in tests, please don't change it
return "no headers parameter declared"
146 changes: 146 additions & 0 deletions tests/test_it_openai_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Request headers reach pipeline wrappers that declare a `headers` parameter, and only those."""

import shutil
from pathlib import Path

import pytest
from fastapi_openai_compat import ChatRequest

from hayhooks.server.pipelines import registry
from hayhooks.server.routers.openai import _build_call_kwargs, _method_accepts_kwarg
from hayhooks.server.utils.base_pipeline_wrapper import BasePipelineWrapper
from hayhooks.settings import settings

# Headers can only reach a wrapper if the installed fastapi-openai-compat forwards them to the
# run_completion callback. Skip the end-to-end checks on versions that predate that.
try:
from fastapi_openai_compat._shared import callable_accepts_kwarg as _compat_opt_in

_COMPAT_FORWARDS_HEADERS = callable(_compat_opt_in)
except ImportError: # pragma: no cover - depends on the installed version
_COMPAT_FORWARDS_HEADERS = False

requires_header_forwarding = pytest.mark.skipif(
not _COMPAT_FORWARDS_HEADERS,
reason="installed fastapi-openai-compat does not forward request headers to run_completion",
)

TEST_FILES_DIR_WITH_HEADERS = Path(__file__).parent / "test_files/files/chat_with_headers"
PIPELINE_FILES_WITH_HEADERS = {
"pipeline_wrapper.py": (TEST_FILES_DIR_WITH_HEADERS / "pipeline_wrapper.py").read_text(),
}

TEST_FILES_DIR_WITHOUT_HEADERS = Path(__file__).parent / "test_files/files/chat_without_headers"
PIPELINE_FILES_WITHOUT_HEADERS = {
"pipeline_wrapper.py": (TEST_FILES_DIR_WITHOUT_HEADERS / "pipeline_wrapper.py").read_text(),
}


@pytest.fixture(autouse=True)
def clear_registry():
registry.clear()
if Path(settings.pipelines_dir).exists():
shutil.rmtree(settings.pipelines_dir)
yield


def _chat(client, model: str, headers: dict[str, str] | None = None):
request = ChatRequest(stream=False, model=model, messages=[{"role": "user", "content": "who am I?"}])
return client.post("/chat/completions", json=request.model_dump(), headers=headers)


@requires_header_forwarding
def test_headers_forwarded_when_wrapper_declares_them(client, deploy_files):
assert deploy_files(client, "with_headers", PIPELINE_FILES_WITH_HEADERS).status_code == 200

response = _chat(client, "with_headers", {"Authorization": "Bearer alice-token"})

assert response.status_code == 200
content = response.json()["choices"][0]["message"]["content"]
assert content == "authorization=Bearer alice-token"


def test_wrapper_without_headers_parameter_is_unaffected(client, deploy_files):
"""The pre-existing (model, messages, body) signature must keep working untouched."""
assert deploy_files(client, "without_headers", PIPELINE_FILES_WITHOUT_HEADERS).status_code == 200

response = _chat(client, "without_headers", {"Authorization": "Bearer bob-token"})

assert response.status_code == 200
content = response.json()["choices"][0]["message"]["content"]
assert content == "no headers parameter declared"


@requires_header_forwarding
def test_wrapper_declaring_headers_without_request_headers(client, deploy_files):
"""A request always carries some headers, so the wrapper still gets a dict, just without ours."""
assert deploy_files(client, "with_headers", PIPELINE_FILES_WITH_HEADERS).status_code == 200

response = _chat(client, "with_headers")

assert response.status_code == 200
assert response.json()["choices"][0]["message"]["content"] == "authorization=missing"


# --- the opt-in helpers ----------------------------------------------------------------------


class _WithHeaders(BasePipelineWrapper):
def setup(self) -> None: ...

def run_chat_completion(self, model: str, messages: list[dict], body: dict, headers: dict[str, str]) -> str:
return "ok"


class _WithKwargs(BasePipelineWrapper):
def setup(self) -> None: ...

def run_chat_completion(self, model: str, messages: list[dict], body: dict, **kwargs) -> str:
return "ok"


class _WithoutHeaders(BasePipelineWrapper):
def setup(self) -> None: ...

def run_chat_completion(self, model: str, messages: list[dict], body: dict) -> str:
return "ok"


def test_method_accepts_kwarg_detects_explicit_parameter():
assert _method_accepts_kwarg(_WithHeaders().run_chat_completion, "headers") is True


def test_method_accepts_kwarg_detects_var_keyword():
assert _method_accepts_kwarg(_WithKwargs().run_chat_completion, "headers") is True


def test_method_accepts_kwarg_rejects_missing_parameter():
assert _method_accepts_kwarg(_WithoutHeaders().run_chat_completion, "headers") is False


def test_method_accepts_kwarg_on_unintrospectable_callable():
"""Callables without a signature must not raise; they simply do not opt in."""
assert _method_accepts_kwarg(print, "headers") is False


def test_build_call_kwargs_includes_headers_for_opted_in_wrapper():
call_kwargs = _build_call_kwargs(
_WithHeaders(), "run_chat_completion", {"messages": []}, {"stream": False}, {"authorization": "Bearer x"}
)

assert call_kwargs == {"messages": [], "body": {"stream": False}, "headers": {"authorization": "Bearer x"}}


def test_build_call_kwargs_omits_headers_for_other_wrappers():
call_kwargs = _build_call_kwargs(
_WithoutHeaders(), "run_chat_completion", {"messages": []}, {"stream": False}, {"authorization": "Bearer x"}
)

assert call_kwargs == {"messages": [], "body": {"stream": False}}


def test_build_call_kwargs_omits_headers_when_none_available():
"""Non-HTTP entry points pass headers=None, which must not reach the wrapper."""
call_kwargs = _build_call_kwargs(_WithHeaders(), "run_chat_completion", {"messages": []}, {}, None)

assert call_kwargs == {"messages": [], "body": {}}
Loading