unsloth/studio/backend/tests/test_openai_tool_passthrough.py
2026-04-16 18:40:59 +00:00

1077 lines
37 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""
Tests for the OpenAI /v1/chat/completions client-side tool pass-through.
Covers:
- ChatCompletionRequest accepts standard OpenAI `tools` / `tool_choice` / `stop`.
- ChatMessage accepts role="tool" with `tool_call_id` and role="assistant"
with `content: None` + `tool_calls`.
- ChatCompletionRequest carries unknown fields via `extra="allow"`.
- anthropic_tool_choice_to_openai() covers all four Anthropic shapes.
- _build_passthrough_payload() honors a caller-supplied tool_choice and
defaults to "auto" when unset.
- _friendly_error() maps httpx transport errors to a "Lost connection"
message so passthrough failures are legible instead of bare 500s.
- _llama_auth_headers() returns a Bearer header only when an API key is set.
- _openai_messages_for_passthrough() splices legacy image_base64 into the
last user message and skips the splice when one is already inline.
- openai_chat_completions() rejects role="tool" / tool_calls-only messages
when the request does not take the passthrough path.
- anthropic_messages() rejects the enable_tools + tool_choice combination.
- _openai_passthrough_non_streaming() wraps httpx transport errors as 502
and returns the upstream JSON body verbatim on success.
- _openai_passthrough_stream() relays data: lines verbatim, breaks on
[DONE], and always emits [DONE] after an upstream error.
No running server or GPU required.
"""
import asyncio
import threading
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
# conftest.py adds the backend root to sys.path so these flat imports resolve.
from models.inference import (
AnthropicMessagesRequest,
ChatCompletionRequest,
ChatMessage,
)
from core.inference.anthropic_compat import (
anthropic_tool_choice_to_openai,
)
from routes import inference as inference_module
from routes.inference import (
_build_passthrough_payload,
_friendly_error,
_llama_auth_headers,
_openai_messages_for_passthrough,
_openai_passthrough_non_streaming,
_openai_passthrough_stream,
)
# =====================================================================
# ChatMessage — tool role, tool_calls, optional content
# =====================================================================
class TestChatMessageToolRoles:
def test_tool_role_with_tool_call_id(self):
msg = ChatMessage(
role = "tool",
tool_call_id = "call_abc123",
content = '{"temperature": 72}',
)
assert msg.role == "tool"
assert msg.tool_call_id == "call_abc123"
assert msg.content == '{"temperature": 72}'
def test_tool_role_with_name(self):
msg = ChatMessage(
role = "tool",
tool_call_id = "call_abc123",
name = "get_weather",
content = '{"temperature": 72}',
)
assert msg.name == "get_weather"
def test_assistant_with_tool_calls_no_content(self):
msg = ChatMessage(
role = "assistant",
content = None,
tool_calls = [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
}
],
)
assert msg.role == "assistant"
assert msg.content is None
assert msg.tool_calls is not None
assert len(msg.tool_calls) == 1
assert msg.tool_calls[0]["function"]["name"] == "get_weather"
def test_assistant_with_content_and_tool_calls(self):
msg = ChatMessage(
role = "assistant",
content = "Let me check the weather.",
tool_calls = [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
}
],
)
assert msg.content == "Let me check the weather."
assert msg.tool_calls[0]["id"] == "call_1"
def test_plain_user_message_still_works(self):
msg = ChatMessage(role = "user", content = "Hello")
assert msg.role == "user"
assert msg.tool_call_id is None
assert msg.tool_calls is None
assert msg.name is None
def test_invalid_role_rejected(self):
with pytest.raises(ValidationError):
ChatMessage(role = "function", content = "x")
def test_content_absent_defaults_to_none(self):
msg = ChatMessage(
role = "assistant",
tool_calls = [
{
"id": "c1",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
)
assert msg.content is None
def test_tool_role_missing_tool_call_id_rejected(self):
# Per OpenAI spec, role="tool" messages must carry tool_call_id so
# upstream backends can associate the result with its prior call.
# Pin the boundary-level rejection so a malformed tool-result
# message never reaches the passthrough path.
with pytest.raises(ValidationError) as exc_info:
ChatMessage(role = "tool", content = '{"temperature": 72}')
assert "tool_call_id" in str(exc_info.value)
def test_tool_role_empty_tool_call_id_rejected(self):
with pytest.raises(ValidationError):
ChatMessage(
role = "tool",
tool_call_id = "",
content = '{"temperature": 72}',
)
# =====================================================================
# ChatCompletionRequest — standard OpenAI tool fields
# =====================================================================
class TestChatCompletionRequestToolFields:
def _make(self, **kwargs):
base = {"messages": [{"role": "user", "content": "Hi"}]}
base.update(kwargs)
return ChatCompletionRequest(**base)
def test_tools_parses(self):
req = self._make(
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Return the weather in a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
],
)
assert req.tools is not None
assert len(req.tools) == 1
assert req.tools[0]["function"]["name"] == "get_weather"
def test_tool_choice_string_auto(self):
assert self._make(tool_choice = "auto").tool_choice == "auto"
def test_tool_choice_string_required(self):
assert self._make(tool_choice = "required").tool_choice == "required"
def test_tool_choice_string_none(self):
assert self._make(tool_choice = "none").tool_choice == "none"
def test_tool_choice_named_function(self):
tc = {"type": "function", "function": {"name": "get_weather"}}
assert self._make(tool_choice = tc).tool_choice == tc
def test_stop_string(self):
assert self._make(stop = "\nUser:").stop == "\nUser:"
def test_stop_list(self):
assert self._make(stop = ["\nUser:", "\nAssistant:"]).stop == [
"\nUser:",
"\nAssistant:",
]
def test_tools_default_none(self):
req = self._make()
assert req.tools is None
assert req.tool_choice is None
assert req.stop is None
def test_extra_fields_accepted(self):
# `frequency_penalty`, `seed`, `response_format` are not yet
# explicitly declared but must survive Pydantic parsing now that
# extra="allow" is set.
req = self._make(
frequency_penalty = 0.5,
seed = 42,
response_format = {"type": "json_object"},
)
# Extras land in model_extra
assert req.model_extra is not None
assert req.model_extra.get("frequency_penalty") == 0.5
assert req.model_extra.get("seed") == 42
assert req.model_extra.get("response_format") == {"type": "json_object"}
def test_unsloth_extensions_still_work(self):
req = self._make(
enable_tools = True,
enabled_tools = ["web_search", "python"],
session_id = "abc",
)
assert req.enable_tools is True
assert req.enabled_tools == ["web_search", "python"]
assert req.session_id == "abc"
def test_stream_defaults_false_matching_openai_spec(self):
# OpenAI's /v1/chat/completions spec defaults `stream` to false.
# Studio previously defaulted to true, which broke naive curl
# clients that omit `stream` (they expect a JSON blob, got SSE).
# Pin the corrected default so it can't silently regress.
req = self._make()
assert req.stream is False
def test_multiturn_tool_loop_messages(self):
req = ChatCompletionRequest(
messages = [
{"role": "user", "content": "What's the weather in Paris?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": '{"temperature": 14, "unit": "celsius"}',
},
],
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object"},
},
}
],
)
assert len(req.messages) == 3
assert req.messages[1].role == "assistant"
assert req.messages[1].content is None
assert req.messages[1].tool_calls[0]["id"] == "call_1"
assert req.messages[2].role == "tool"
assert req.messages[2].tool_call_id == "call_1"
# =====================================================================
# anthropic_tool_choice_to_openai — pure translation helper
# =====================================================================
class TestAnthropicToolChoiceToOpenAI:
def test_auto(self):
assert anthropic_tool_choice_to_openai({"type": "auto"}) == "auto"
def test_any_becomes_required(self):
assert anthropic_tool_choice_to_openai({"type": "any"}) == "required"
def test_none(self):
assert anthropic_tool_choice_to_openai({"type": "none"}) == "none"
def test_tool_named(self):
result = anthropic_tool_choice_to_openai(
{"type": "tool", "name": "get_weather"}
)
assert result == {
"type": "function",
"function": {"name": "get_weather"},
}
def test_tool_missing_name_returns_none(self):
assert anthropic_tool_choice_to_openai({"type": "tool"}) is None
def test_none_input_returns_none(self):
assert anthropic_tool_choice_to_openai(None) is None
def test_unrecognized_shape_returns_none(self):
assert anthropic_tool_choice_to_openai({"type": "wibble"}) is None
assert anthropic_tool_choice_to_openai("auto") is None
assert anthropic_tool_choice_to_openai(42) is None
# =====================================================================
# _build_passthrough_payload — tool_choice propagation
# =====================================================================
class TestBuildPassthroughPayloadToolChoice:
def _args(self):
return dict(
openai_messages = [{"role": "user", "content": "Hi"}],
openai_tools = [
{
"type": "function",
"function": {"name": "f", "parameters": {"type": "object"}},
}
],
temperature = 0.6,
top_p = 0.95,
top_k = 20,
max_tokens = 128,
stream = False,
)
def test_default_tool_choice_is_auto(self):
body = _build_passthrough_payload(**self._args())
assert body["tool_choice"] == "auto"
def test_override_tool_choice_required(self):
body = _build_passthrough_payload(**self._args(), tool_choice = "required")
assert body["tool_choice"] == "required"
def test_override_tool_choice_none(self):
body = _build_passthrough_payload(**self._args(), tool_choice = "none")
assert body["tool_choice"] == "none"
def test_override_tool_choice_named_function(self):
tc = {"type": "function", "function": {"name": "f"}}
body = _build_passthrough_payload(**self._args(), tool_choice = tc)
assert body["tool_choice"] == tc
def test_stream_adds_include_usage(self):
args = self._args()
args["stream"] = True
body = _build_passthrough_payload(**args)
assert body.get("stream_options") == {"include_usage": True}
def test_repetition_penalty_renamed(self):
body = _build_passthrough_payload(**self._args(), repetition_penalty = 1.1)
assert body.get("repeat_penalty") == 1.1
assert "repetition_penalty" not in body
# =====================================================================
# _friendly_error — httpx transport failures
# =====================================================================
class TestFriendlyErrorHttpx:
"""The async pass-through helpers talk to llama-server via httpx.
When the subprocess is down, httpx raises RequestError subclasses
whose string form (``"All connection attempts failed"``, ``"[Errno 111]
Connection refused"``, ...) does NOT contain the substring
``"Lost connection to llama-server"`` the sync path uses, so the
previous substring-only `_friendly_error` returned a useless generic
message. These tests pin the new isinstance-based mapping.
"""
def _req(self):
return httpx.Request("POST", "http://127.0.0.1:65535/v1/chat/completions")
def test_connect_error_mapped(self):
exc = httpx.ConnectError("All connection attempts failed", request = self._req())
assert "Lost connection" in _friendly_error(exc)
def test_read_error_mapped(self):
exc = httpx.ReadError("EOF", request = self._req())
assert "Lost connection" in _friendly_error(exc)
def test_remote_protocol_error_mapped(self):
exc = httpx.RemoteProtocolError("peer closed", request = self._req())
assert "Lost connection" in _friendly_error(exc)
def test_read_timeout_mapped(self):
exc = httpx.ReadTimeout("timed out", request = self._req())
assert "Lost connection" in _friendly_error(exc)
def test_non_httpx_unchanged(self):
# Non-httpx exceptions still fall through to the existing substring
# heuristics — a context-size message must still produce the
# "Message too long" path.
ctx_msg = (
"request (4096 tokens) exceeds the available context size (2048 tokens)"
)
assert "Message too long" in _friendly_error(ValueError(ctx_msg))
def test_generic_exception_returns_generic_message(self):
assert (
_friendly_error(RuntimeError("unrelated")) == "An internal error occurred"
)
# =====================================================================
# _llama_auth_headers — Bearer header only when API key is set
# =====================================================================
class TestLlamaAuthHeaders:
def test_returns_bearer_header_when_api_key_set(self):
class _Backend:
_api_key = "k_secret_123"
assert _llama_auth_headers(_Backend()) == {
"Authorization": "Bearer k_secret_123",
}
def test_returns_none_when_api_key_none(self):
class _Backend:
_api_key = None
assert _llama_auth_headers(_Backend()) is None
def test_returns_none_when_api_key_attribute_missing(self):
class _Backend:
pass
assert _llama_auth_headers(_Backend()) is None
def test_returns_none_when_api_key_empty_string(self):
class _Backend:
_api_key = ""
assert _llama_auth_headers(_Backend()) is None
# =====================================================================
# _openai_messages_for_passthrough — legacy image_base64 splice
# =====================================================================
_TINY_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYA"
"AjCB0C8AAAAASUVORK5CYII="
)
def _inline_image_part(b64 = _TINY_PNG_B64):
return {
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
class _PayloadWithImage:
"""Minimal stand-in for ChatCompletionRequest; only attributes read by
_openai_messages_for_passthrough matter here."""
def __init__(self, messages, image_base64 = None):
self.messages = messages
self.image_base64 = image_base64
class TestOpenAIMessagesForPassthrough:
def test_prior_user_image_does_not_block_new_base64(self):
payload = _PayloadWithImage(
messages = [
ChatMessage(
role = "user",
content = [{"type": "text", "text": "q1"}, _inline_image_part()],
),
ChatMessage(role = "assistant", content = "a1"),
ChatMessage(role = "user", content = "q2 no inline"),
],
image_base64 = _TINY_PNG_B64,
)
out = _openai_messages_for_passthrough(payload)
assert isinstance(out[-1]["content"], list)
assert any(part.get("type") == "image_url" for part in out[-1]["content"])
def test_last_user_with_inline_image_skips_splice(self):
payload = _PayloadWithImage(
messages = [
ChatMessage(role = "user", content = "earlier"),
ChatMessage(
role = "user",
content = [
{"type": "text", "text": "last"},
_inline_image_part(),
],
),
],
image_base64 = _TINY_PNG_B64,
)
out = _openai_messages_for_passthrough(payload)
last_parts = out[-1]["content"]
assert sum(1 for part in last_parts if part.get("type") == "image_url") == 1
def test_no_user_messages_appends_trailing_user(self):
payload = _PayloadWithImage(
messages = [ChatMessage(role = "system", content = "sys")],
image_base64 = _TINY_PNG_B64,
)
out = _openai_messages_for_passthrough(payload)
assert out[-1]["role"] == "user"
assert any(part.get("type") == "image_url" for part in out[-1]["content"])
def test_only_last_of_multiple_user_turns_receives_splice(self):
payload = _PayloadWithImage(
messages = [
ChatMessage(role = "user", content = "u1"),
ChatMessage(role = "assistant", content = "a1"),
ChatMessage(role = "user", content = "u2"),
ChatMessage(role = "assistant", content = "a2"),
ChatMessage(role = "user", content = "u3"),
],
image_base64 = _TINY_PNG_B64,
)
out = _openai_messages_for_passthrough(payload)
assert isinstance(out[0]["content"], str)
assert isinstance(out[2]["content"], str)
assert isinstance(out[4]["content"], list)
# =====================================================================
# openai_chat_completions — tool-shape guards on non-passthrough paths
# =====================================================================
class _FakeLlamaBackend:
def __init__(self, is_loaded = True, supports_tools = True, is_vision = False):
self.is_loaded = is_loaded
self.supports_tools = supports_tools
self.is_vision = is_vision
self._is_audio = False
self.model_identifier = "stub"
self.base_url = "http://127.0.0.1:0"
self._api_key = None
class _FakeInferenceBackend:
active_model_name = "hf"
models = {"hf": {}}
class _FakeFastAPIRequest:
async def is_disconnected(self):
return False
async def _marker_stream(*args, **kwargs):
return ("passthrough_stream", None)
async def _marker_nonstream(*args, **kwargs):
return ("passthrough_nonstream", None)
async def _call_openai_chat_completions(payload, llama):
with (
patch.object(inference_module, "get_llama_cpp_backend", return_value = llama),
patch.object(
inference_module,
"get_inference_backend",
return_value = _FakeInferenceBackend(),
),
patch.object(
inference_module, "_openai_passthrough_stream", new = _marker_stream
),
patch.object(
inference_module,
"_openai_passthrough_non_streaming",
new = _marker_nonstream,
),
):
return await inference_module.openai_chat_completions(
payload,
_FakeFastAPIRequest(),
current_subject = "u",
)
class TestOpenAIChatCompletionsToolGuards:
"""When the request does NOT take the tool-passthrough path, messages
with role="tool" or assistant tool_calls-only must be rejected at the
route boundary rather than producing an opaque upstream error."""
def _tool_result_payload(self, **extra):
return ChatCompletionRequest(
messages = [
{"role": "user", "content": "q"},
{"role": "tool", "tool_call_id": "c1", "content": "r"},
],
**extra,
)
def test_role_tool_on_non_gguf_rejected(self):
payload = self._tool_result_payload()
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
_call_openai_chat_completions(
payload,
_FakeLlamaBackend(is_loaded = False),
)
)
assert exc_info.value.status_code == 400
def test_role_tool_on_gguf_without_tool_support_rejected(self):
payload = self._tool_result_payload()
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
_call_openai_chat_completions(
payload,
_FakeLlamaBackend(supports_tools = False),
)
)
assert exc_info.value.status_code == 400
def test_role_tool_with_enable_tools_true_rejected(self):
payload = self._tool_result_payload(enable_tools = True)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(_call_openai_chat_completions(payload, _FakeLlamaBackend()))
assert exc_info.value.status_code == 400
def test_assistant_tool_calls_only_rejected_when_no_passthrough(self):
payload = ChatCompletionRequest(
messages = [
{"role": "user", "content": "q"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
],
)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
_call_openai_chat_completions(
payload,
_FakeLlamaBackend(supports_tools = False),
)
)
assert exc_info.value.status_code == 400
def test_plain_user_message_does_not_trip_tool_guard(self):
payload = ChatCompletionRequest(
messages = [{"role": "user", "content": "plain"}],
)
try:
asyncio.run(
_call_openai_chat_completions(
payload,
_FakeLlamaBackend(supports_tools = False),
)
)
except HTTPException as exc:
# Downstream paths may fail for unrelated reasons; only assert
# the tool-shape guard did NOT fire.
assert "role='tool'" not in (exc.detail or "")
assert "tool_calls-only" not in (exc.detail or "")
# =====================================================================
# anthropic_messages — enable_tools + tool_choice conflict
# =====================================================================
class TestAnthropicEnableToolsToolChoiceConflict:
"""Server-side agentic loop (enable_tools=True) does not honor
tool_choice. Reject the combination at the route boundary with 400
so callers don't silently see their tool_choice dropped."""
def _payload(self, *, enable_tools, tool_choice):
return AnthropicMessagesRequest(
model = "default",
max_tokens = 64,
messages = [{"role": "user", "content": "q"}],
tool_choice = tool_choice,
enable_tools = enable_tools,
)
def test_enable_tools_true_with_tool_choice_raises_400(self):
payload = self._payload(enable_tools = True, tool_choice = {"type": "any"})
with patch.object(
inference_module,
"get_llama_cpp_backend",
return_value = _FakeLlamaBackend(),
):
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
inference_module.anthropic_messages(
payload,
_FakeFastAPIRequest(),
current_subject = "u",
)
)
assert exc_info.value.status_code == 400
assert "tool_choice" in exc_info.value.detail
assert "enable_tools" in exc_info.value.detail
def test_enable_tools_false_with_tool_choice_skips_guard(self):
payload = self._payload(enable_tools = False, tool_choice = {"type": "any"})
with patch.object(
inference_module,
"get_llama_cpp_backend",
return_value = _FakeLlamaBackend(),
):
try:
asyncio.run(
inference_module.anthropic_messages(
payload,
_FakeFastAPIRequest(),
current_subject = "u",
)
)
except HTTPException as exc:
assert not (
exc.status_code == 400
and "tool_choice is not honored" in (exc.detail or "")
)
except Exception:
# Any other failure downstream is fine; we only pin that
# the enable_tools+tool_choice guard does NOT fire.
pass
# =====================================================================
# _openai_passthrough_non_streaming — verbatim body + httpx 502 mapping
# =====================================================================
class _FakeLlamaBase:
base_url = "http://127.0.0.1:0"
_api_key = None
def _openai_tools_payload(stream = False):
return ChatCompletionRequest(
messages = [{"role": "user", "content": "q"}],
tools = [
{
"type": "function",
"function": {"name": "f", "parameters": {"type": "object"}},
}
],
stream = stream,
)
def _mock_async_client_post(status_code, *, json_body = None, text_body = ""):
class _Client:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, *args, **kwargs):
resp = MagicMock()
resp.status_code = status_code
resp.text = text_body
resp.json = lambda: (json_body if json_body is not None else {})
return resp
return _Client
def _mock_async_client_raise(exc_factory):
class _Client:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, *args, **kwargs):
raise exc_factory()
return _Client
class TestOpenAIPassthroughNonStreaming:
def test_verbatim_json_body_returned_on_success(self):
native = {
"id": "chatcmpl-foo",
"object": "chat.completion",
"model": "qwen-native",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
}
],
},
}
],
"usage": {"prompt_tokens": 42, "completion_tokens": 7, "total_tokens": 49},
}
client_cls = _mock_async_client_post(200, json_body = native)
with patch.object(inference_module.httpx, "AsyncClient", client_cls):
resp = asyncio.run(
_openai_passthrough_non_streaming(
_FakeLlamaBase(), _openai_tools_payload()
)
)
import json
body = json.loads(resp.body.decode("utf-8"))
assert body == native
assert body["choices"][0]["finish_reason"] == "tool_calls"
def test_preserves_native_id_and_model(self):
native = {
"id": "chatcmpl-native-xyz",
"model": "llama-native",
"choices": [
{
"finish_reason": "stop",
"message": {"role": "assistant", "content": "ok"},
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
client_cls = _mock_async_client_post(200, json_body = native)
with patch.object(inference_module.httpx, "AsyncClient", client_cls):
resp = asyncio.run(
_openai_passthrough_non_streaming(
_FakeLlamaBase(), _openai_tools_payload()
)
)
import json
body = json.loads(resp.body.decode("utf-8"))
assert body["id"] == "chatcmpl-native-xyz"
assert body["model"] == "llama-native"
def test_httpx_connect_error_mapped_to_502(self):
client_cls = _mock_async_client_raise(
lambda: httpx.ConnectError(
"refused",
request = httpx.Request("POST", "http://x"),
)
)
with patch.object(inference_module.httpx, "AsyncClient", client_cls):
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
_openai_passthrough_non_streaming(
_FakeLlamaBase(),
_openai_tools_payload(),
)
)
assert exc_info.value.status_code == 502
assert "Lost connection" in exc_info.value.detail
def test_httpx_read_error_mapped_to_502(self):
client_cls = _mock_async_client_raise(
lambda: httpx.ReadError("eof", request = httpx.Request("POST", "http://x")),
)
with patch.object(inference_module.httpx, "AsyncClient", client_cls):
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
_openai_passthrough_non_streaming(
_FakeLlamaBase(),
_openai_tools_payload(),
)
)
assert exc_info.value.status_code == 502
# =====================================================================
# _openai_passthrough_stream — verbatim data: lines, [DONE] on error
# =====================================================================
class _FakeStreamResponse:
"""Stand-in for httpx.Response that yields a fixed list of SSE lines."""
def __init__(self, lines, status_code = 200):
self.status_code = status_code
self._lines = list(lines)
def aiter_lines(self):
parent = self
class _Iter:
def __init__(self):
self._idx = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._idx >= len(parent._lines):
raise StopAsyncIteration
v = parent._lines[self._idx]
self._idx += 1
return v
async def aclose(self):
pass
return _Iter()
async def aread(self):
return b""
async def aclose(self):
pass
def _run_passthrough_stream(*, send_returns = None, send_raises = None):
"""Drive _openai_passthrough_stream with a fake httpx client and return
the list of emitted SSE chunks (decoded to str)."""
async def _send(req, stream):
if send_raises is not None:
raise send_raises()
return send_returns
with patch.object(inference_module.httpx, "AsyncClient") as mock_cls:
instance = MagicMock()
instance.build_request = MagicMock(return_value = MagicMock())
instance.send = _send
instance.aclose = AsyncMock()
mock_cls.return_value = instance
resp = asyncio.run(
_openai_passthrough_stream(
_FakeFastAPIRequest(),
threading.Event(),
_FakeLlamaBase(),
_openai_tools_payload(stream = True),
)
)
async def _collect():
return [
chunk if isinstance(chunk, str) else chunk.decode("utf-8")
async for chunk in resp.body_iterator
]
return asyncio.run(_collect())
class TestOpenAIPassthroughStreamVerbatim:
def test_relays_data_lines_verbatim(self):
chunks = _run_passthrough_stream(
send_returns = _FakeStreamResponse(
[
'data: {"id":"abc","choices":[{"delta":{"content":"hi"}}]}',
"data: [DONE]",
]
),
)
assert any('"id":"abc"' in c for c in chunks)
assert any("data: [DONE]" in c for c in chunks)
def test_ignores_blank_and_non_data_lines(self):
chunks = _run_passthrough_stream(
send_returns = _FakeStreamResponse(
[
"",
": heartbeat",
'data: {"x":1}',
"data: [DONE]",
]
),
)
for chunk in chunks:
assert chunk.startswith("data: ") or chunk == ""
assert any('"x":1' in c for c in chunks)
def test_breaks_on_done(self):
chunks = _run_passthrough_stream(
send_returns = _FakeStreamResponse(
[
'data: {"a":1}',
"data: [DONE]",
'data: {"should_not_appear":true}',
]
),
)
assert not any("should_not_appear" in c for c in chunks)
class TestOpenAIPassthroughStreamErrorTermination:
"""On upstream failure (non-200 or transport exception), the stream
must emit an SSE error chunk followed by `data: [DONE]` so clients
that wait for [DONE] don't hang."""
def test_done_emitted_after_non_200_error(self):
class _Resp:
status_code = 500
async def aread(self):
return b"server oops"
async def aclose(self):
pass
chunks = _run_passthrough_stream(send_returns = _Resp())
assert any('"error"' in c for c in chunks)
assert any(c.strip() == "data: [DONE]" for c in chunks)
def test_done_emitted_after_transport_exception(self):
chunks = _run_passthrough_stream(
send_raises = lambda: httpx.ConnectError(
"boom",
request = httpx.Request("POST", "http://x"),
),
)
assert any('"error"' in c for c in chunks)
assert any(c.strip() == "data: [DONE]" for c in chunks)
def test_done_comes_after_error_chunk(self):
chunks = _run_passthrough_stream(
send_raises = lambda: httpx.ReadError(
"reset",
request = httpx.Request("POST", "http://x"),
),
)
err_idx = next(i for i, c in enumerate(chunks) if '"error"' in c)
done_idx = next(i for i, c in enumerate(chunks) if c.strip() == "data: [DONE]")
assert done_idx > err_idx