mirror of
https://github.com/deepseek-ai/deepseek-recipe.git
synced 2026-10-28 17:17:08 +08:00
Co-authored-by: xinchengxx <xinchengxx@deepseek.com> Co-authored-by: XieJiSS <xiejiss@deepseek.com> Co-authored-by: yqaty <jiayu@deepseek.com> Co-authored-by: zhu-he <hezhu@deepseek.com> Co-authored-by: season-guo <seasonguo@deepseek.com>
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""Smoke tests for the Rust conversion, rendering, response, and image bindings."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from deepseek_recipe import (
|
|
IMAGE_SPECIAL_TOKEN,
|
|
THINKING_END_TOKEN,
|
|
ChatCompletionRequest,
|
|
ChatCompletionResponse,
|
|
Conversation,
|
|
ConversionError,
|
|
ConversionOptions,
|
|
DeepseekV41Encoding,
|
|
ImageQuota,
|
|
ImageResolver,
|
|
InferenceChunk,
|
|
InferenceFinishReason,
|
|
MessagesRequest,
|
|
OpenCvImagePreprocessor,
|
|
PromptUsage,
|
|
ReqwestImageFetcher,
|
|
ResponsesRequest,
|
|
StreamProcessor,
|
|
Tokenizer,
|
|
ToolDefinition,
|
|
UserMessage,
|
|
WebSearchBehavior,
|
|
)
|
|
|
|
REQUEST = {
|
|
"model": "smoke-model",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
}
|
|
|
|
TOKENIZER_PATH = (
|
|
Path(__file__).resolve().parents[2] / "static" / "tokenizers" / "v41" / "tokenizer.json"
|
|
)
|
|
|
|
|
|
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_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)
|
|
|
|
assert prompt_ids == tokenizer.encode(encoding.render_conversation(converted.conversation).prompt)
|
|
|
|
|
|
def test_web_search_conversion_options():
|
|
defaults = ConversionOptions()
|
|
constructed = ConversionOptions(
|
|
default_thinking_mode=False,
|
|
responses_web_search=WebSearchBehavior.Reject,
|
|
messages_web_search=WebSearchBehavior.Ignore,
|
|
)
|
|
configured = (
|
|
defaults.with_default_thinking_mode(False)
|
|
.with_responses_web_search(WebSearchBehavior.Reject)
|
|
.with_messages_web_search(WebSearchBehavior.Ignore)
|
|
)
|
|
assert defaults.default_thinking_mode
|
|
assert defaults.responses_web_search == WebSearchBehavior.Ignore
|
|
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.Ignore
|
|
|
|
responses = {
|
|
"model": "smoke-model",
|
|
"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_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:
|
|
assert converted.inference_options.temperature == 0.25
|
|
for chunk in (
|
|
InferenceChunk.ready(prompt_usage=PromptUsage(prompt_tokens=4)),
|
|
InferenceChunk.text(
|
|
THINKING_END_TOKEN + "Custom answer", content_tokens=2
|
|
),
|
|
InferenceChunk.finish(InferenceFinishReason.Stop),
|
|
):
|
|
for output in processor.push(chunk):
|
|
response.append(output)
|
|
for output in processor.finish():
|
|
response.append(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_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(THINKING_END_TOKEN + "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.token(token_id) for token_id in token_ids),
|
|
InferenceChunk.finish(InferenceFinishReason.Stop),
|
|
):
|
|
for output in processor.push(chunk):
|
|
response.append(output)
|
|
for output in processor.finish():
|
|
response.append(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(THINKING_END_TOKEN + "🦀")
|
|
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.token(token_id) for token_id in token_ids),
|
|
InferenceChunk.finish(InferenceFinishReason.Stop),
|
|
):
|
|
for output in processor.push(chunk):
|
|
response.append(output)
|
|
for output in processor.finish():
|
|
response.append(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.token(999_999_999),
|
|
InferenceChunk.text(THINKING_END_TOKEN + "Hi", content_tokens=1),
|
|
InferenceChunk.finish(InferenceFinishReason.Stop),
|
|
):
|
|
for output in processor.push(chunk):
|
|
response.append(output)
|
|
for output in processor.finish():
|
|
response.append(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.token(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 IMAGE_SPECIAL_TOKEN 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
|
|
|
|
|
|
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
|
|
|