1050 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Smoke tests for the Rust conversion, rendering, response, and image bindings."""
import http.server
import json
import sys
import threading
from pathlib import Path
import pytest
from deepseek_recipe import (
IMAGE_SPECIAL_TOKEN_ID,
ChatCompletionRequest,
ChatCompletionResponse,
Conversation,
ConversionError,
ConversionOptions,
DeepseekV4Encoding,
DeepseekV41Encoding,
ImageError,
ImageQuota,
ImageResolver,
InferenceChunk,
InferenceFinishReason,
MessagesRequest,
MessagesResponse,
ModelVersion,
OpenCvImagePreprocessor,
PromptUsage,
ReasoningContentBehavior,
ReqwestImageFetcher,
ResponsesRequest,
ResponsesResponse,
SpecialToken,
SpecialTokens,
StreamProcessor,
SystemMessage,
ThinkingInitialStage,
Tokenizer,
ToolDefinition,
ToolMessage,
UrlImageSource,
UserMessage,
WebSearchBehavior,
)
REQUEST = {
"model": "DeepSeek-V4.1-Flash",
"messages": [{"role": "user", "content": "hi"}],
}
RESPONSES_REQUEST = {"model": "DeepSeek-V4.1-Flash", "input": "hi"}
TOKENIZER_PATH = (
Path(__file__).resolve().parents[2] / "static" / "tokenizers" / "v41" / "tokenizer.json"
)
def run_processor(generator, parsing_options, chunks, tokenizer=None):
"""Push `chunks` through a stream processor and return every protocol chunk."""
processor = StreamProcessor(generator, parsing_options, tokenizer)
outputs = []
try:
for chunk in chunks:
outputs.extend(processor.push(chunk))
outputs.extend(processor.finish())
finally:
processor.close()
return outputs
def answer_chunks(content, finish_reason=InferenceFinishReason.Stop):
"""Return the inference chunks of one answer that closes an empty reasoning block."""
return [
InferenceChunk.ready(),
InferenceChunk.text(SpecialToken.ThinkingEnd.default_str() + content, token_count=1),
InferenceChunk.finish(finish_reason),
]
def test_rendering():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
encoding = DeepseekV41Encoding()
rendered = encoding.render_conversation(Conversation(messages=[UserMessage("hi")]))
assert converted.conversation.messages[0].content == "hi"
assert "hi" in rendered.prompt
assert rendered.image_sources == []
assert encoding.render_conversation(converted.conversation).prompt == rendered.prompt
def test_system_message_images():
source = UrlImageSource("https://example.com/a.png")
message = SystemMessage("<|image|>", [source])
conversation = Conversation(messages=[message])
rendered = DeepseekV41Encoding().render_conversation(conversation)
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
ids = DeepseekV41Encoding().with_tokenizer(tokenizer).encode(conversation)
assert rendered.image_sources[0].url == source.url
assert ids.count(IMAGE_SPECIAL_TOKEN_ID) == 1
def test_special_tokens_mapping():
assert SpecialTokens().token(SpecialToken.User) == SpecialToken.User.default_str()
custom = SpecialTokens(user="<u>", image="IMG")
configured = ConversionOptions().with_special_tokens(custom)
assert configured.special_tokens.token(SpecialToken.User) == "<u>"
rendered = (
DeepseekV41Encoding()
.with_special_tokens(custom)
.render_conversation(Conversation(messages=[UserMessage("hi")]))
)
assert "<u>" in rendered.prompt
converted = ChatCompletionRequest(
{
"model": "DeepSeek-V4.1-Flash",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hi"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/a.png"},
},
],
}
],
}
).convert(configured)
assert converted.conversation.messages[0].content == "hi\n\nIMG"
def test_encoding_with_tokenizer():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
with pytest.raises(RuntimeError, match="no tokenizer"):
DeepseekV41Encoding().encode(converted.conversation)
encoding = DeepseekV41Encoding().with_tokenizer(tokenizer)
prompt_ids = encoding.encode(converted.conversation)
# The prompt holds no special-token spelling in plain text, so encoding
# with the full vocabulary gives the same IDs.
assert prompt_ids == tokenizer.encode(encoding.render_conversation(converted.conversation).prompt)
@pytest.mark.parametrize("encoding_class", [DeepseekV4Encoding, DeepseekV41Encoding])
def test_prompt_encoding_preserves_input_with_configured_tokenizer(encoding_class):
text = "Please preserve every word in this message."
conversation = Conversation(messages=[UserMessage(text)], thinking_mode=False)
reference = Tokenizer.from_file(str(TOKENIZER_PATH))
config = json.loads(TOKENIZER_PATH.read_text())
config["padding"] = {
"strategy": "BatchLongest",
"direction": "Right",
"pad_to_multiple_of": 8,
"pad_id": 0,
"pad_type_id": 0,
"pad_token": "[PAD]",
}
config["truncation"] = {
"direction": "Right",
"max_length": 2,
"strategy": "LongestFirst",
"stride": 0,
}
configured = Tokenizer.from_str(json.dumps(config))
direct_ids = configured.encode(text)
assert len(direct_ids) == 8
assert encoding_class().with_tokenizer(configured).encode(conversation) == (
encoding_class().with_tokenizer(reference).encode(conversation)
)
# Preparing the prompt encoder must not change direct tokenizer usage.
assert configured.encode(text) == direct_ids
def test_prompt_encoding_differs_from_tokenizer_on_special_token_spellings():
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
encoding = DeepseekV41Encoding().with_tokenizer(tokenizer)
conversation = Conversation(messages=[UserMessage("a<|User|>")], thinking_mode=False)
rendered = encoding.render_conversation(conversation)
(user_token_id,) = tokenizer.encode(SpecialToken.User.default_str())
prompt_ids = encoding.encode(conversation)
full_vocabulary_ids = tokenizer.encode(rendered.prompt)
# Prompt encoding encodes the spelling in the message as plain text; only
# the template's token is the special token ID. `Tokenizer.encode` encodes
# both occurrences as that ID.
assert prompt_ids.count(user_token_id) == 1
assert full_vocabulary_ids.count(user_token_id) == 2
assert prompt_ids != full_vocabulary_ids
def test_conversation_constructor_structured_output_formats():
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
with_schema = Conversation(messages=[UserMessage("hi")], response_schema=schema)
assert with_schema.response_format == "json_schema"
assert with_schema.response_schema == schema
assert with_schema.response_regex is None
# Rendering writes the schema into the prompt.
assert '"properties"' in DeepseekV41Encoding().render_conversation(with_schema).prompt
with_regex = Conversation(response_regex="[0-9]+")
assert with_regex.response_format == "regex"
assert with_regex.response_regex == "[0-9]+"
assert with_regex.response_schema is None
assert Conversation(response_format="json_object").response_format == "json_object"
with pytest.raises(ValueError, match="cannot be combined"):
Conversation(response_schema=schema, response_regex="[0-9]+")
with pytest.raises(ValueError, match="cannot be combined"):
Conversation(response_format="json_object", response_schema=schema)
with pytest.raises(ValueError, match="cannot be combined"):
Conversation(response_format="json_object", response_regex="[0-9]+")
@pytest.mark.parametrize(
"model,deferred",
[
("DeepSeek-V4-Pro", False),
("DeepSeek-V4.1-Flash", True),
],
)
def test_deferred_loading_follows_model_version(model, deferred):
body = {
"model": model,
"messages": [
{"role": "user", "content": "hi"},
{
"role": "system",
"content": [
{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "lookup"}},
],
},
],
"tools": [
{"name": "search", "input_schema": {"type": "object"}},
{"name": "lookup", "input_schema": {"type": "object"}, "defer_loading": True},
],
}
converted = MessagesRequest(body).convert(ConversionOptions())
encoding = DeepseekV41Encoding() if deferred else DeepseekV4Encoding()
prompt = encoding.render_conversation(converted.conversation).prompt
assert converted.conversation.tools[1].defer_loading is deferred
assert ("### Updated Tool Schemas" in prompt) is deferred
assert '"name": "lookup"' in prompt
def test_web_search_conversion_options():
defaults = ConversionOptions()
constructed = ConversionOptions(
default_thinking_mode=False,
responses_web_search=WebSearchBehavior.Reject,
messages_web_search=WebSearchBehavior.Drop,
)
configured = (
defaults.with_default_thinking_mode(False)
.with_responses_web_search(WebSearchBehavior.Reject)
.with_messages_web_search(WebSearchBehavior.Drop)
)
assert defaults.default_thinking_mode
assert defaults.responses_web_search == WebSearchBehavior.Drop
assert defaults.messages_web_search == WebSearchBehavior.Reject
for options in (constructed, configured):
assert not options.default_thinking_mode
assert options.responses_web_search == WebSearchBehavior.Reject
assert options.messages_web_search == WebSearchBehavior.Drop
responses = {
"model": "DeepSeek-V4.1-Flash",
"input": "hi",
"tools": [{"type": "web_search"}],
}
messages = {
**REQUEST,
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
}
assert ResponsesRequest(responses).convert(defaults).conversation.tools == []
with pytest.raises(ConversionError, match="server tools are not supported"):
MessagesRequest(messages).convert(defaults)
converted = MessagesRequest(messages).convert(configured)
assert converted.conversation.tools == []
assert not converted.conversation.thinking_mode
with pytest.raises(ConversionError, match="Server tools are not supported"):
ResponsesRequest(responses).convert(configured)
def test_reasoning_content_conversion_options():
defaults = ConversionOptions()
constructed = ConversionOptions(
reasoning_content_behavior=ReasoningContentBehavior.Reject
)
configured = defaults.with_reasoning_content_behavior(
ReasoningContentBehavior.Reject
)
assert defaults.reasoning_content_behavior == ReasoningContentBehavior.FillEmpty
for options in (constructed, configured):
assert options.reasoning_content_behavior == ReasoningContentBehavior.Reject
request = {
"model": "DeepSeek-V4.1-Flash",
"thinking": {"type": "enabled"},
"messages": [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_00_abcdefghijklmnopqrst0000",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call_00_abcdefghijklmnopqrst0000",
"content": "ok",
},
],
}
with pytest.raises(ConversionError, match="must be passed back"):
ChatCompletionRequest(request).convert(constructed)
converted = ChatCompletionRequest(request).convert(defaults)
assistant = converted.conversation.messages[1]
assert assistant.reasoning_content == ""
def test_model_version_mapping():
# The model name selects the version; the table defaults to the model names.
defaults = ConversionOptions()
assert defaults.model_versions_by_name == {
"DeepSeek-V4-Pro": ModelVersion.DeepseekV4Pro,
"DeepSeek-V4.1-Flash": ModelVersion.DeepseekV41Flash,
}
overridden = defaults.with_model_alias("custom-model", ModelVersion.DeepseekV4Pro)
assert overridden.model_versions_by_name == {
**defaults.model_versions_by_name,
"custom-model": ModelVersion.DeepseekV4Pro,
}
bulk = defaults.with_model_versions_by_name(
{"custom-model": ModelVersion.DeepseekV4Pro, "another-model": ModelVersion.DeepseekV41Flash}
)
assert bulk.model_versions_by_name == {
"custom-model": ModelVersion.DeepseekV4Pro,
"another-model": ModelVersion.DeepseekV41Flash,
}
with pytest.raises(TypeError):
defaults.with_model_alias("custom-model", "DeepSeek-V4-Pro")
body = {"model": "custom-model", "messages": [{"role": "user", "content": "hi"}]}
with pytest.raises(ConversionError, match="supported API model names"):
ChatCompletionRequest(body).convert(defaults)
assert ChatCompletionRequest(body).convert(overridden).model_version == ModelVersion.DeepseekV4Pro
ChatCompletionRequest(body).convert(bulk)
converted = ChatCompletionRequest({**body, "model": "DeepSeek-V4.1-Flash"}).convert(defaults)
assert converted.model_version == ModelVersion.DeepseekV41Flash
with pytest.raises(ConversionError, match="supported API model names"):
ChatCompletionRequest({**body, "model": "DeepSeek-V4.1-Flash"}).convert(bulk)
# A model version is hashable, so it can key a table of prompt encodings.
encodings = {ModelVersion.DeepseekV4Pro: "v4", ModelVersion.DeepseekV41Flash: "v41"}
assert encodings[converted.model_version] == "v41"
def test_protocol_requests_expose_model_until_converted():
for request_type, body in (
(ChatCompletionRequest, REQUEST),
(ResponsesRequest, RESPONSES_REQUEST),
(MessagesRequest, REQUEST),
):
request = request_type(body)
assert request.model == "DeepSeek-V4.1-Flash"
request.convert(ConversionOptions())
with pytest.raises(RuntimeError, match="consumed"):
request.model
with pytest.raises(RuntimeError, match="consumed"):
request.convert(ConversionOptions())
def test_output_constraints_reach_inference_options():
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
strict_tools = ChatCompletionRequest({
**REQUEST,
"thinking": {"type": "disabled"},
"tools": [{"type": "function", "function": {"name": "f", "strict": True, "parameters": schema}}],
}).convert(ConversionOptions())
constraint = strict_tools.inference_options.output_constraint
assert constraint.kind == "tool_call"
assert json.loads(constraint.schema)[0]["function"]["parameters"] == {
**schema, "additionalProperties": False, "required": ["a"],
}
unconstrained = ResponsesRequest(RESPONSES_REQUEST).convert(ConversionOptions())
assert unconstrained.inference_options.output_constraint is None
def test_resolve_max_tokens_reports_the_context_length_error():
thinking = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
assert thinking.resolve_max_tokens(10, 131072) == 65536
with pytest.raises(ConversionError, match="maximum context length is 131072 tokens") as rejected:
thinking.resolve_max_tokens(131073, 131072)
assert rejected.value.status_code == 400
def test_max_tokens_upper_bound_rejects_or_clamps():
options = ConversionOptions(thinking_max_tokens_upper_bound=100, non_thinking_max_tokens_upper_bound=50)
# A thinking request uses the thinking bound; `thinking: disabled` and
# `reasoning.effort: none` select the other bound.
with pytest.raises(ConversionError, match=r"valid range of max_tokens is \[1, 100\]"):
ChatCompletionRequest({**REQUEST, "max_tokens": 200}).convert(options)
with pytest.raises(ConversionError, match=r"valid range of max_tokens is \[1, 50\]"):
ChatCompletionRequest(
{**REQUEST, "max_tokens": 100, "thinking": {"type": "disabled"}}
).convert(options)
with pytest.raises(ConversionError, match=r"valid range of max_tokens is \[1, 100\]"):
ResponsesRequest({**RESPONSES_REQUEST, "max_output_tokens": 200}).convert(options)
with pytest.raises(ConversionError, match=r"valid range of max_tokens is \[1, 50\]"):
ResponsesRequest(
{**RESPONSES_REQUEST, "max_output_tokens": 100, "reasoning": {"effort": "none"}}
).convert(options)
clamped = MessagesRequest({**REQUEST, "max_tokens": 200}).convert(options)
assert clamped.inference_options.max_tokens == 100
clamped = MessagesRequest(
{**REQUEST, "max_tokens": 200, "thinking": {"type": "disabled"}}
).convert(options)
assert clamped.inference_options.max_tokens == 50
def test_custom_inference():
converted = ChatCompletionRequest({**REQUEST, "temperature": 0.25}).convert(
ConversionOptions()
)
generator = ChatCompletionRequest.chunk_generator(converted, "smoke-response", "smoke-model")
processor = StreamProcessor(generator, converted.parsing_options)
response = ChatCompletionResponse("smoke-response", "smoke-model", 0, 0, 0)
try:
# Thinking mode samples at temperature 1.0.
assert converted.inference_options.temperature == 1.0
non_thinking = ChatCompletionRequest(
{**REQUEST, "temperature": 0.25, "thinking": {"type": "disabled"}}
).convert(ConversionOptions())
assert non_thinking.inference_options.temperature == 0.25
for chunk in (
InferenceChunk.ready(prompt_usage=PromptUsage(prompt_tokens=4)),
InferenceChunk.text(
SpecialToken.ThinkingEnd.default_str() + "Custom answer", token_count=2
),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.accumulate(output)
for output in processor.finish():
response.accumulate(output)
body = json.loads(response.to_json())
assert processor.finished
assert body["id"] == "smoke-response"
assert body["choices"][0]["message"]["content"] == "Custom answer"
assert body["usage"]["prompt_tokens"] == 4
assert body["usage"]["completion_tokens"] == 2
finally:
processor.close()
def test_responses_echo_request_settings():
request = ResponsesRequest(
{
"model": "DeepSeek-V4.1-Flash",
"input": "hi",
"instructions": "be brief",
"temperature": 0.5,
"tools": [{"type": "function", "name": "f"}],
"user": "u",
}
)
converted = request.convert(ConversionOptions())
generator = ResponsesRequest.chunk_generator(
converted, "smoke-response", "smoke-model"
)
processor = StreamProcessor(generator, converted.parsing_options)
response = ResponsesResponse("smoke-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
InferenceChunk.text("hello", token_count=1),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.accumulate(output)
for output in processor.finish():
response.accumulate(output)
body = json.loads(response.to_json())
assert body["instructions"] == "be brief"
# The response reports the requested temperature, not the sampling one.
assert converted.inference_options.temperature == 1.0
assert body["temperature"] == 0.5
assert body["tools"] == [
{"type": "function", "name": "f", "description": None, "parameters": None, "strict": None}
]
assert body["user"] == "u"
assert body["top_p"] == 1.0
finally:
processor.close()
def test_token_chunks():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
token_ids = tokenizer.encode(SpecialToken.ThinkingEnd.default_str() + "Custom answer")
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("token-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
*(InferenceChunk.tokens([token_id]) for token_id in token_ids),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.accumulate(output)
for output in processor.finish():
response.accumulate(output)
body = json.loads(response.to_json())
assert body["choices"][0]["message"]["content"] == "Custom answer"
assert body["usage"]["completion_tokens"] == len(token_ids)
finally:
processor.close()
def test_split_character_tokens():
# A character outside the vocabulary decodes from several byte tokens; the
# ids buffered while it was incomplete still count as completion tokens.
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
token_ids = tokenizer.encode(SpecialToken.ThinkingEnd.default_str() + "🦀")
assert len(token_ids) > 2
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("token-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
*(InferenceChunk.tokens([token_id]) for token_id in token_ids),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.accumulate(output)
for output in processor.finish():
response.accumulate(output)
body = json.loads(response.to_json())
assert body["choices"][0]["message"]["content"] == "🦀"
assert body["usage"]["completion_tokens"] == len(token_ids)
finally:
processor.close()
def test_undecodable_token_ignored():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("token-response", "smoke-model", 0, 0, 0)
try:
for chunk in (
InferenceChunk.ready(),
InferenceChunk.tokens([999_999_999]),
InferenceChunk.text(SpecialToken.ThinkingEnd.default_str() + "Hi", token_count=1),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.accumulate(output)
for output in processor.finish():
response.accumulate(output)
body = json.loads(response.to_json())
assert body["choices"][0]["message"]["content"] == "Hi"
assert body["usage"]["completion_tokens"] == 1
finally:
processor.close()
def test_token_chunk_without_tokenizer_errors():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "token-response", "smoke-model")
processor = StreamProcessor(generator, converted.parsing_options)
try:
with pytest.raises(RuntimeError, match="without a tokenizer"):
processor.push(InferenceChunk.tokens([1]))
finally:
processor.close()
def test_tokenizer_from_str():
# Loading from the file's contents gives the same ids as loading the file.
text = TOKENIZER_PATH.read_text()
assert Tokenizer.from_str(text).encode("Custom answer") == Tokenizer.from_file(
str(TOKENIZER_PATH)
).encode("Custom answer")
def test_tokenizer_from_str_errors():
with pytest.raises(RuntimeError):
Tokenizer.from_str("not json")
def test_foreign_tokenizer_errors():
converted = ChatCompletionRequest(REQUEST).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "py-response", "smoke-model")
with pytest.raises(TypeError):
StreamProcessor(generator, converted.parsing_options, object())
def test_image_resolution():
# One 50-by-40 RGB PNG exercises the native image decoder and WebP encoder.
png = (
"iVBORw0KGgoAAAANSUhEUgAAADIAAAAoCAIAAAAzED4bAAAAPElEQVR4nO3OUQkAIBBA"
"sQvht/2jGMsYPmSwAJu1T9A8H2hpaWlpaTVoaWkVaGlpFWhpaRVoaWkVaH3QuqTYo"
"YpppISkAAAAAElFTkSuQmCC"
)
converted = ChatCompletionRequest({
**REQUEST,
"messages": [{"role": "user", "content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + png},
}]}],
}).convert(ConversionOptions())
rendered = DeepseekV41Encoding().render_conversation(converted.conversation)
resolver = ImageResolver(ReqwestImageFetcher(), OpenCvImagePreprocessor())
multimodal = resolver.resolve(rendered.image_sources, ImageQuota())
assert SpecialToken.Image.default_str() in rendered.prompt
(image,) = multimodal.images
assert image.width > 0 and image.height > 0
assert image.data[:4] == b"RIFF" and image.data[8:12] == b"WEBP"
assert multimodal.image_token_adjustment() > 0
UNSUPPORTED_IMAGE = (
"You have uploaded an unsupported image. Please make sure your image is valid and has one "
"of the following formats: webp, png, jpeg, and gif."
)
# Valid base64 with a PNG media type that does not decode as an image.
UNDECODABLE_PNG_DATA_URL = "data:image/png;base64,AAAAAAAA"
# A 1-by-1 PNG.
TINY_PNG = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
@pytest.mark.parametrize(
("request_type", "body", "expected"),
[
(
ChatCompletionRequest,
{
**REQUEST,
"messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + TINY_PNG}},
{"type": "image_url", "image_url": {"url": UNDECODABLE_PNG_DATA_URL}},
]}],
},
f".messages[0].image[1]: {UNSUPPORTED_IMAGE}",
),
(
MessagesRequest,
{
"model": "DeepSeek-V4.1-Flash",
"max_tokens": 16,
"messages": [
{"role": "system", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": TINY_PNG}},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAAAAAAA"}},
]},
{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": TINY_PNG}},
]},
],
},
f"messages.0.image[1]: {UNSUPPORTED_IMAGE}",
),
(
ResponsesRequest,
{
"model": "DeepSeek-V4.1-Flash",
"input": [{"role": "user", "content": [
{"type": "input_image", "image_url": "data:image/png;base64," + TINY_PNG},
{"type": "input_image", "image_url": UNDECODABLE_PNG_DATA_URL},
]}],
},
f"input[0].image[1]: {UNSUPPORTED_IMAGE}",
),
],
ids=["chat", "messages", "responses"],
)
def test_image_errors_report_the_request_location(request_type, body, expected):
converted = request_type(json.dumps(body)).convert(ConversionOptions())
rendered = DeepseekV41Encoding().render_conversation(converted.conversation)
assert all(source.path for source in rendered.image_sources)
resolver = ImageResolver(ReqwestImageFetcher(), OpenCvImagePreprocessor())
with pytest.raises(ImageError) as rejected:
resolver.resolve(rendered.image_sources, ImageQuota())
assert str(rejected.value) == expected
assert rejected.value.kind == "UnsupportedMediaType"
def test_destination_filter_rejects_addresses():
checked = []
def reject_all(host, address):
checked.append((host, address))
return False
fetcher = ReqwestImageFetcher().with_destination_filter(reject_all)
with pytest.raises(ImageError) as rejected:
fetcher.fetch("http://127.0.0.1:9/image.png")
assert rejected.value.kind == "DestinationRejected"
assert not rejected.value.is_retryable
assert checked == [("127.0.0.1", "127.0.0.1")]
with pytest.raises(ImageError) as rejected:
fetcher.fetch("http://localhost:9/image.png")
assert rejected.value.kind == "DestinationRejected"
# The resolved address depends on the host's name resolution.
assert any(host == "localhost" for host, _ in checked)
class ImageHandler(http.server.BaseHTTPRequestHandler):
"""Serve three bytes at any path, and redirect `/redirect` to `localhost`."""
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", f"http://localhost:{self.server.server_port}/image.png")
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Length", "3")
self.end_headers()
self.wfile.write(b"abc")
def log_message(self, *args):
pass
@pytest.fixture
def image_server():
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), ImageHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def test_destination_filter_allows_addresses(image_server):
url = f"http://127.0.0.1:{image_server.server_port}/image.png"
fetcher = ReqwestImageFetcher().with_destination_filter(lambda host, address: True)
assert fetcher.fetch(url) == b"abc"
def test_destination_filter_rejects_redirect_target(image_server):
checked = []
def loopback_only(host, address):
checked.append(host)
return host == "127.0.0.1"
fetcher = ReqwestImageFetcher().with_destination_filter(loopback_only)
assert fetcher.fetch(f"http://127.0.0.1:{image_server.server_port}/image.png") == b"abc"
with pytest.raises(ImageError) as rejected:
fetcher.fetch(f"http://127.0.0.1:{image_server.server_port}/redirect")
assert rejected.value.kind == "DestinationRejected"
# A fetch outside a resolve call has no request location, so the text
# starts with the image index alone.
assert str(rejected.value) == (
f"image[0]: Failed to download image from http://127.0.0.1:{image_server.server_port}/redirect"
)
assert checked[-1] == "localhost"
def test_destination_filter_returning_non_bool_rejects(monkeypatch):
reported = []
monkeypatch.setattr(sys, "unraisablehook", reported.append)
fetcher = ReqwestImageFetcher().with_destination_filter(lambda host, address: "yes")
with pytest.raises(ImageError) as rejected:
fetcher.fetch("http://127.0.0.1:9/image.png")
assert rejected.value.kind == "DestinationRejected"
assert [type(report.exc_value) for report in reported] == [TypeError]
def test_destination_filter_that_raises_rejects(monkeypatch):
reported = []
monkeypatch.setattr(sys, "unraisablehook", reported.append)
def broken(host, address):
raise RuntimeError("filter failure")
fetcher = ReqwestImageFetcher().with_destination_filter(broken)
with pytest.raises(ImageError) as rejected:
fetcher.fetch("http://127.0.0.1:9/image.png")
assert rejected.value.kind == "DestinationRejected"
assert [type(report.exc_value) for report in reported] == [RuntimeError]
def test_circular_dict_rejected():
# A self-referencing container must raise instead of exhausting the stack.
parameters = {}
parameters["self"] = parameters
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=parameters)
def test_circular_list_rejected():
parameters = []
parameters.append(parameters)
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=parameters)
def test_indirect_cycle_rejected():
outer = {}
inner = {"outer": outer}
outer["inner"] = inner
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=outer)
def test_cycle_through_tuple_rejected():
parameters = {"items": []}
parameters["items"].append((parameters,))
with pytest.raises(ValueError, match="circular reference"):
ToolDefinition(name="f", parameters=parameters)
def test_circular_request_body_rejected():
body = dict(REQUEST)
body["self"] = body
with pytest.raises(ValueError, match="circular reference"):
ChatCompletionRequest(body)
def test_deep_nesting_rejected():
parameters = {}
node = parameters
for _ in range(200):
child = {}
node["next"] = child
node = child
with pytest.raises(RecursionError):
ToolDefinition(name="f", parameters=parameters)
def test_nesting_within_limit_accepted():
parameters = {}
node = parameters
for _ in range(127):
child = {}
node["next"] = child
node = child
assert ToolDefinition(name="f", parameters=parameters).parameters == parameters
def test_shared_subtree_accepted():
# A reference used twice is a shared subtree, not a circular reference.
shared = {"type": "string"}
parameters = {"type": "object", "properties": {"a": shared, "b": shared}}
assert ToolDefinition(name="f", parameters=parameters).parameters == parameters
def test_token_chunks_carry_log_probabilities():
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
converted = ChatCompletionRequest(
{**REQUEST, "logprobs": True, "top_logprobs": 1, "thinking": {"type": "disabled"}}
).convert(ConversionOptions())
assert converted.inference_options.logprobs
assert converted.inference_options.top_logprobs == 1
generator = ChatCompletionRequest.chunk_generator(converted, "logprobs", "smoke-model")
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
response = ChatCompletionResponse("logprobs", "smoke-model", 0, 0, 0)
token_ids = tokenizer.encode("Hi there")
try:
for chunk in (
InferenceChunk.ready(),
InferenceChunk.tokens(
token_ids, [(-0.5, [(token_ids[0], -0.5)]) for _ in token_ids]
),
InferenceChunk.finish(InferenceFinishReason.Stop),
):
for output in processor.push(chunk):
response.accumulate(output)
for output in processor.finish():
response.accumulate(output)
body = json.loads(response.to_json())
logprobs = body["choices"][0]["logprobs"]["content"]
assert "".join(entry["token"] for entry in logprobs) == "Hi there"
assert all(entry["logprob"] == -0.5 for entry in logprobs)
assert logprobs[0]["top_logprobs"][0]["bytes"] == list(logprobs[0]["token"].encode())
finally:
processor.close()
def test_image_placeholders_split_content():
source = UrlImageSource("https://example.com/a.png")
image_token = SpecialToken.Image.default_str()
encoding = DeepseekV41Encoding()
def placeholders(rendered):
return [
(start, end)
for start, end in rendered.special_ranges
if rendered.prompt[start:end] == image_token
]
# One spelling per source becomes a placeholder; the extra spelling stays plain text.
message = UserMessage(f"{image_token}a{image_token}b{image_token}", [source, source])
rendered = encoding.render_conversation(Conversation(messages=[message], thinking_mode=False))
assert len(placeholders(rendered)) == 2
assert rendered.prompt.count(image_token) == 3
assert [image.url for image in rendered.image_sources] == [source.url, source.url]
assert message.content == f"{image_token}a{image_token}b{image_token}"
# Fewer spellings than sources: every source is kept without a placeholder.
message = ToolMessage("no placeholder", "call_1", [source])
rendered = encoding.render_conversation(Conversation(messages=[message], thinking_mode=False))
assert placeholders(rendered) == []
assert image_token not in rendered.prompt
assert len(rendered.image_sources) == 1
assert len(message.image_sources) == 1
def test_messages_accept_an_explicit_image_token():
custom = SpecialTokens(image="IMG")
encoding = DeepseekV41Encoding().with_special_tokens(custom)
source = UrlImageSource("https://example.com/a.png")
for message in (
SystemMessage("see IMG", [source], image_token="IMG"),
UserMessage("see IMG", [source], image_token="IMG"),
ToolMessage("see IMG", "call_1", [source], image_token="IMG"),
):
rendered = encoding.render_conversation(Conversation(messages=[message], thinking_mode=False))
assert [rendered.prompt[start:end] for start, end in rendered.special_ranges if rendered.prompt[start:end] == "IMG"] == ["IMG"]
# Without the keyword, the custom spelling is plain text and no placeholder is written.
rendered = encoding.render_conversation(
Conversation(messages=[UserMessage("see IMG", [source])], thinking_mode=False)
)
assert all(rendered.prompt[start:end] != "IMG" for start, end in rendered.special_ranges)
with pytest.raises(ValueError, match="image_token must not be empty"):
SystemMessage("x", image_token="")
with pytest.raises(ValueError, match="image_token must not be empty"):
UserMessage("x", image_token="")
with pytest.raises(ValueError, match="image_token must not be empty"):
ToolMessage("x", image_token="")
def test_responses_namespace_tool_map():
namespace = {"type": "namespace", "name": "ns", "tools": [{"type": "function", "name": "f"}]}
request = ResponsesRequest({**RESPONSES_REQUEST, "tools": [namespace]})
namespace_tool_map = request.namespace_tool_map(ConversionOptions())
assert list(namespace_tool_map.values()) == [("ns", "f")]
(function_name,) = namespace_tool_map
assert "ns" in function_name and "f" in function_name
converted = request.convert(ConversionOptions())
assert [tool.name for tool in converted.conversation.tools] == [function_name]
with pytest.raises(RuntimeError, match="consumed"):
request.namespace_tool_map(ConversionOptions())
generator = (
ResponsesRequest.chunk_generator(converted, "ns", "m")
.with_namespace_tool_map(namespace_tool_map)
)
outputs = run_processor(generator, converted.parsing_options, answer_chunks("hello"))
assert ResponsesResponse.sse_event_name(outputs[-1]) == "response.completed"
invalid = {**namespace, "tools": [{"type": "custom", "name": "c"}]}
with pytest.raises(ConversionError, match="custom tools are not allowed inside a namespace"):
ResponsesRequest({**RESPONSES_REQUEST, "tools": [invalid]}).namespace_tool_map(
ConversionOptions()
)
def test_logprob_count_mismatch_closes_the_processor():
tokenizer = Tokenizer.from_file(str(TOKENIZER_PATH))
converted = ChatCompletionRequest({**REQUEST, "logprobs": True}).convert(ConversionOptions())
generator = ChatCompletionRequest.chunk_generator(converted, "logprobs", "m")
processor = StreamProcessor(generator, converted.parsing_options, tokenizer)
try:
processor.push(InferenceChunk.ready())
with pytest.raises(RuntimeError, match="1 log probabilities for 2 token IDs"):
processor.push(InferenceChunk.tokens([1, 2], [(0.0, [])]))
assert not processor.finished
with pytest.raises(RuntimeError, match="closed"):
processor.push(InferenceChunk.text("x"))
with pytest.raises(RuntimeError, match="closed"):
processor.finish()
finally:
processor.close()
def test_the_thinking_mode_selects_the_thinking_initial_stage():
thinking = ChatCompletionRequest(REQUEST).convert(ConversionOptions()).parsing_options
assert thinking.thinking_initial_stage == ThinkingInitialStage.ReasoningStart
non_thinking = ChatCompletionRequest(
{**REQUEST, "thinking": {"type": "disabled"}}
).convert(ConversionOptions())
assert non_thinking.parsing_options.thinking_initial_stage is None
def test_sse_framing_of_the_three_responses():
assert ChatCompletionResponse.sse_done_data() == "[DONE]"
assert ResponsesResponse.sse_done_data() is None
assert MessagesResponse.sse_done_data() is None
for request_type, response_type, body, first_event, last_event in (
(ChatCompletionRequest, ChatCompletionResponse, REQUEST, None, None),
(
ResponsesRequest,
ResponsesResponse,
RESPONSES_REQUEST,
"response.created",
"response.completed",
),
(MessagesRequest, MessagesResponse, REQUEST, "message_start", "message_stop"),
):
converted = request_type(body).convert(ConversionOptions())
generator = request_type.chunk_generator(converted, "sse", "m")
outputs = run_processor(generator, converted.parsing_options, answer_chunks("x"))
assert outputs
assert response_type.sse_event_name(outputs[0]) == first_event
assert response_type.sse_event_name(outputs[-1]) == last_event
response = response_type("sse", "m", 0, 0, 0)
for output in outputs:
response.accumulate(output)
assert json.loads(response.to_json())["id"] == "sse"
with pytest.raises(RuntimeError, match="consumed"):
response_type.sse_event_name(outputs[0])