mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-17 19:19:12 +02:00
* 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>
343 lines
11 KiB
Python
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])
|