fastmcp/tests/client/sampling/handlers/test_openai_handler.py
Bill Easton d6b55c0b2f
Raise on unhandled content types in sampling handler dispatch chains (#3857)
* Raise on unhandled content types in sampling handler dispatch chains

The Anthropic and OpenAI sampling handlers have isinstance chains that
dispatch on MCP content types but silently drop unhandled variants like
EmbeddedResource and ResourceLink. This adds explicit else-raise guards
to match the Gemini handler's behavior and the single-content dispatch
paths that already raise.

Raising is the right choice over warn-and-skip: a partial conversion
produces a plausible-but-wrong LLM response (the model confidently
answers based on incomplete input), which is worse than a clear error
that tells the user exactly what isn't supported.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tests for unsupported content type raises in sampling handlers

Tests the new ValueError raises for unsupported content types
(e.g. EmbeddedResource) in the Anthropic and OpenAI message
conversion loops. Uses model_construct to bypass Pydantic's
union validation since the raise is a defensive guard for
future SDK content types.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:52:31 -04:00

343 lines
11 KiB
Python

from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp.types import (
AudioContent,
CreateMessageRequestParams,
CreateMessageResult,
EmbeddedResource,
ImageContent,
ModelHint,
ModelPreferences,
SamplingMessage,
TextContent,
TextResourceContents,
ToolUseContent,
)
from openai import AsyncOpenAI
from openai.types.chat import (
ChatCompletion,
ChatCompletionAssistantMessageParam,
ChatCompletionContentPartImageParam,
ChatCompletionContentPartInputAudioParam,
ChatCompletionContentPartTextParam,
ChatCompletionMessage,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion import Choice
from pydantic import AnyUrl
from fastmcp.client.sampling.handlers.openai import (
OpenAISamplingHandler,
_audio_content_to_openai_part,
_image_content_to_openai_part,
)
def test_convert_sampling_messages_to_openai_messages():
msgs = OpenAISamplingHandler._convert_to_openai_messages(
system_prompt="sys",
messages=[
SamplingMessage(
role="user", content=TextContent(type="text", text="hello")
),
SamplingMessage(
role="assistant", content=TextContent(type="text", text="ok")
),
],
)
assert msgs == [
ChatCompletionSystemMessageParam(content="sys", role="system"),
ChatCompletionUserMessageParam(content="hello", role="user"),
ChatCompletionAssistantMessageParam(content="ok", role="assistant"),
]
def test_image_content_to_openai_part():
part = _image_content_to_openai_part(
ImageContent(type="image", data="YWJj", mimeType="image/png")
)
assert part == ChatCompletionContentPartImageParam(
type="image_url",
image_url={"url": "data:image/png;base64,YWJj"},
)
def test_audio_content_to_openai_part_wav():
part = _audio_content_to_openai_part(
AudioContent(type="audio", data="YWJj", mimeType="audio/wav")
)
assert part == ChatCompletionContentPartInputAudioParam(
type="input_audio",
input_audio={"data": "YWJj", "format": "wav"},
)
def test_audio_content_to_openai_part_mp3():
part = _audio_content_to_openai_part(
AudioContent(type="audio", data="YWJj", mimeType="audio/mpeg")
)
assert part["input_audio"]["format"] == "mp3"
def test_audio_content_to_openai_part_unsupported_raises():
with pytest.raises(ValueError, match="Unsupported audio MIME type"):
_audio_content_to_openai_part(
AudioContent(type="audio", data="YWJj", mimeType="audio/ogg")
)
def test_image_content_to_openai_part_unsupported_raises():
with pytest.raises(ValueError, match="Unsupported image MIME type"):
_image_content_to_openai_part(
ImageContent(type="image", data="YWJj", mimeType="image/bmp")
)
def test_convert_single_image_content_to_openai_message():
msgs = OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="user",
content=ImageContent(type="image", data="YWJj", mimeType="image/png"),
)
],
)
assert len(msgs) == 1
assert msgs[0] == ChatCompletionUserMessageParam(
role="user",
content=[
ChatCompletionContentPartImageParam(
type="image_url",
image_url={"url": "data:image/png;base64,YWJj"},
)
],
)
def test_convert_single_audio_content_to_openai_message():
msgs = OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="user",
content=AudioContent(type="audio", data="YWJj", mimeType="audio/wav"),
)
],
)
assert len(msgs) == 1
assert msgs[0] == ChatCompletionUserMessageParam(
role="user",
content=[
ChatCompletionContentPartInputAudioParam(
type="input_audio",
input_audio={"data": "YWJj", "format": "wav"},
)
],
)
def test_convert_list_content_with_image_and_text():
msgs = OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="user",
content=[
TextContent(type="text", text="What is in this image?"),
ImageContent(type="image", data="YWJj", mimeType="image/jpeg"),
],
)
],
)
assert len(msgs) == 1
assert msgs[0] == ChatCompletionUserMessageParam(
role="user",
content=[
ChatCompletionContentPartTextParam(
type="text", text="What is in this image?"
),
ChatCompletionContentPartImageParam(
type="image_url",
image_url={"url": "data:image/jpeg;base64,YWJj"},
),
],
)
def test_convert_image_in_assistant_message_raises():
with pytest.raises(ValueError, match="ImageContent is only supported in user"):
OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="assistant",
content=ImageContent(
type="image", data="YWJj", mimeType="image/png"
),
)
],
)
def test_convert_audio_in_assistant_message_raises():
with pytest.raises(ValueError, match="AudioContent is only supported in user"):
OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="assistant",
content=AudioContent(
type="audio", data="YWJj", mimeType="audio/wav"
),
)
],
)
def test_convert_list_image_in_assistant_message_raises():
"""Image/audio in an assistant list-content message should raise, not silently drop."""
with pytest.raises(ValueError, match="only supported in user messages"):
OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="assistant",
content=[
TextContent(type="text", text="Here's the image"),
ImageContent(type="image", data="YWJj", mimeType="image/png"),
],
)
],
)
def test_convert_list_tool_calls_with_image_raises():
"""Image/audio alongside tool_calls in assistant list should raise."""
with pytest.raises(ValueError, match="only supported in user messages"):
OpenAISamplingHandler._convert_to_openai_messages(
system_prompt=None,
messages=[
SamplingMessage(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="my_tool",
input={"arg": "val"},
),
ImageContent(type="image", data="YWJj", mimeType="image/png"),
],
)
],
)
@pytest.mark.parametrize(
"prefs,expected",
[
("gpt-4o-mini", "gpt-4o-mini"),
(ModelPreferences(hints=[ModelHint(name="gpt-4o-mini")]), "gpt-4o-mini"),
(["gpt-4o-mini", "other"], "gpt-4o-mini"),
(None, "fallback-model"),
(["unknown-model"], "fallback-model"),
],
)
def test_select_model_from_preferences(prefs: Any, expected: str) -> None:
mock_client = MagicMock(spec=AsyncOpenAI)
handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
assert handler._select_model_from_preferences(prefs) == expected
async def test_handler_passes_max_completion_tokens():
"""Verify the handler uses max_completion_tokens (not max_tokens)."""
mock_client = MagicMock(spec=AsyncOpenAI)
mock_client.chat = MagicMock()
mock_client.chat.completions = MagicMock()
mock_client.chat.completions.create = AsyncMock(
return_value=ChatCompletion(
id="123",
created=123,
model="gpt-4o-mini",
object="chat.completion",
choices=[
Choice(
message=ChatCompletionMessage(content="hi", role="assistant"),
finish_reason="stop",
index=0,
)
],
)
)
handler = OpenAISamplingHandler(default_model="gpt-4o-mini", client=mock_client)
messages = [
SamplingMessage(role="user", content=TextContent(type="text", text="hello"))
]
params = CreateMessageRequestParams(messages=messages, maxTokens=300)
await handler(messages, params, context=None) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
call_kwargs = mock_client.chat.completions.create.call_args
assert "max_completion_tokens" in call_kwargs.kwargs
assert call_kwargs.kwargs["max_completion_tokens"] == 300
assert "max_tokens" not in call_kwargs.kwargs
async def test_chat_completion_to_create_message_result():
mock_client = MagicMock(spec=AsyncOpenAI)
handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
mock_client.chat.completions.create.return_value = ChatCompletion(
id="123",
created=123,
model="gpt-4o-mini",
object="chat.completion",
choices=[
Choice(
message=ChatCompletionMessage(
content="HELPFUL CONTENT FROM A VERY SMART LLM", role="assistant"
),
finish_reason="stop",
index=0,
)
],
)
result: CreateMessageResult = handler._chat_completion_to_create_message_result(
chat_completion=mock_client.chat.completions.create.return_value
)
assert result == CreateMessageResult(
content=TextContent(type="text", text="HELPFUL CONTENT FROM A VERY SMART LLM"),
role="assistant",
model="gpt-4o-mini",
)
def test_convert_messages_raises_on_unsupported_content_type():
"""Unsupported content types should raise ValueError.
SamplingMessage validates content against a union of known types, so
we use model_construct to bypass validation and simulate a future
SDK content type that the handler doesn't know about yet.
"""
embedded = EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri=AnyUrl("file:///test.txt"), text="hello", mimeType="text/plain"
),
)
msg = SamplingMessage.model_construct(
role="user",
content=[TextContent(type="text", text="prefix"), embedded],
)
with pytest.raises(ValueError, match="Unsupported content type for OpenAI"):
OpenAISamplingHandler._convert_to_openai_messages(None, [msg])