diff --git a/tests/xllm_service/CMakeLists.txt b/tests/xllm_service/CMakeLists.txt index 2132e68..ac87b45 100644 --- a/tests/xllm_service/CMakeLists.txt +++ b/tests/xllm_service/CMakeLists.txt @@ -2,3 +2,4 @@ add_subdirectory(chat_template) add_subdirectory(common) add_subdirectory(http_service) add_subdirectory(scheduler) +add_subdirectory(tokenizer) diff --git a/tests/xllm_service/chat_template/CMakeLists.txt b/tests/xllm_service/chat_template/CMakeLists.txt index 4eef720..0ecd74f 100644 --- a/tests/xllm_service/chat_template/CMakeLists.txt +++ b/tests/xllm_service/chat_template/CMakeLists.txt @@ -9,3 +9,33 @@ cc_test( :message_projection GTest::gtest_main ) + +cc_test( + NAME + model_type_test + SRCS + model_type_test.cpp + DEPS + :model_type + GTest::gtest_main +) + +cc_test( + NAME + chat_template_test + SRCS + jinja_chat_template_test.cpp + DEPS + :chat_template + GTest::gtest_main +) + +cc_test( + NAME + deepseek_v4_cpp_chat_template_test + SRCS + deepseek_v4_cpp_chat_template_test.cpp + DEPS + :deepseek_v4_cpp_chat_template + GTest::gtest_main +) diff --git a/tests/xllm_service/chat_template/deepseek_v4_cpp_chat_template_test.cpp b/tests/xllm_service/chat_template/deepseek_v4_cpp_chat_template_test.cpp new file mode 100644 index 0000000..5171972 --- /dev/null +++ b/tests/xllm_service/chat_template/deepseek_v4_cpp_chat_template_test.cpp @@ -0,0 +1,167 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "chat_template/deepseek_v4_cpp_chat_template.h" + +#include + +#include + +#include "chat_template/chat_template.h" +#include "chat_template/jinja_chat_template.h" +#include "tokenizer/tokenizer_args.h" + +namespace xllm_service { +namespace { + +TokenizerArgs make_v4_args() { + TokenizerArgs args; + args.bos_token("<|begin▁of▁sentence|>"); + return args; +} + +TEST(DeepseekV4CppChatTemplate, EncodeDoesNotAddSpecialTokens) { + std::unique_ptr tmpl = + std::make_unique(make_v4_args()); + EXPECT_FALSE(tmpl->encode_add_special_tokens()); +} + +TEST(DeepseekV4CppChatTemplate, PlainTextMatchesUpstreamGolden) { + DeepseekV4CppChatTemplate tmpl(make_v4_args()); + + ChatMessages messages; + messages.emplace_back("system", "You are a helpful assistant."); + messages.emplace_back("user", "Hello"); + + auto prompt = tmpl.apply(messages, {}, nlohmann::ordered_json::object()); + ASSERT_TRUE(prompt.has_value()); + EXPECT_EQ(*prompt, + "<|begin▁of▁sentence|>You are a helpful assistant." + "<|User|>Hello<|Assistant|>"); +} + +TEST(DeepseekV4CppChatTemplate, MMContentVecIsFlattened) { + DeepseekV4CppChatTemplate tmpl(make_v4_args()); + + // Adjacent text blocks are flattened (joined by '\n') for the upstream + // template. + Message::MMContentVec blocks{Message::MMContent("text", "first"), + Message::MMContent("text", "second")}; + ChatMessages messages{Message("user", blocks)}; + + auto prompt = tmpl.apply(messages, {}, nlohmann::ordered_json::object()); + ASSERT_TRUE(prompt.has_value()); + EXPECT_EQ(*prompt, + "<|begin▁of▁sentence|><|User|>first\nsecond" + "<|Assistant|>"); +} + +TEST(DeepseekV4CppChatTemplate, ToolCallsBlockAndReasoningAreProjected) { + DeepseekV4CppChatTemplate tmpl(make_v4_args()); + + ChatMessages messages; + messages.emplace_back("user", "weather?"); + + Message assistant("assistant", ""); + Message::ToolCall call; + call.id = "call_001"; + call.type = "function"; + call.function.name = "get_weather"; + call.function.arguments = R"({"location":"Beijing"})"; + assistant.tool_calls = Message::ToolCallVec{call}; + assistant.reasoning_content = "Need weather data."; + messages.push_back(assistant); + + nlohmann::ordered_json kwargs = nlohmann::ordered_json::object(); + kwargs["thinking"] = true; + auto prompt = tmpl.apply(messages, {}, kwargs); + ASSERT_TRUE(prompt.has_value()); + + EXPECT_NE(prompt->find("<|DSML|tool_calls>"), std::string::npos); + EXPECT_NE(prompt->find("Need weather data."), std::string::npos); +} + +TEST(DeepseekV4CppChatTemplate, ToolResultMergesViaToolCallId) { + DeepseekV4CppChatTemplate tmpl(make_v4_args()); + + ChatMessages messages; + messages.emplace_back("user", "weather?"); + + Message assistant("assistant", ""); + Message::ToolCall call; + call.id = "call_001"; + call.type = "function"; + call.function.name = "get_weather"; + call.function.arguments = R"({"location":"Beijing"})"; + assistant.tool_calls = Message::ToolCallVec{call}; + messages.push_back(assistant); + + // Non-empty tool_call_id drives tool-result merging. + Message tool_msg("tool", R"({"temperature":22})"); + tool_msg.tool_call_id = "call_001"; + messages.push_back(tool_msg); + + auto prompt = tmpl.apply(messages, {}, nlohmann::ordered_json::object()); + ASSERT_TRUE(prompt.has_value()); + + EXPECT_NE(prompt->find("<|User|>{\"temperature\":22}" + "<|Assistant|>"), + std::string::npos); +} + +TEST(DeepseekV4CppChatTemplate, ToolsInjectionRendersV4Schema) { + DeepseekV4CppChatTemplate tmpl(make_v4_args()); + + ChatMessages messages; + messages.emplace_back("user", "weather in beijing"); + + std::vector tools; + JsonTool tool; + tool.type = "function"; + tool.function.name = "get_weather"; + tool.function.description = "query weather"; + tool.function.parameters = nlohmann::json{ + {"type", "object"}, {"properties", {{"city", {{"type", "string"}}}}}}; + tools.push_back(tool); + + auto prompt = tmpl.apply(messages, tools, nlohmann::ordered_json::object()); + ASSERT_TRUE(prompt.has_value()); + + EXPECT_NE(prompt->find("### Available Tool Schemas"), std::string::npos); + EXPECT_NE(prompt->find("get_weather"), std::string::npos); +} + +TEST(DeepseekV4CppChatTemplate, ThinkingKwargTogglesThinkBlock) { + DeepseekV4CppChatTemplate tmpl(make_v4_args()); + + ChatMessages messages{Message("user", "Hello")}; + + nlohmann::ordered_json kwargs = nlohmann::ordered_json::object(); + kwargs["thinking"] = true; + auto thinking_on = tmpl.apply(messages, {}, kwargs); + ASSERT_TRUE(thinking_on.has_value()); + EXPECT_EQ(*thinking_on, + "<|begin▁of▁sentence|><|User|>Hello<|Assistant|>"); + + // Default (no kwarg) closes thinking immediately. + auto thinking_off = + tmpl.apply(messages, {}, nlohmann::ordered_json::object()); + ASSERT_TRUE(thinking_off.has_value()); + EXPECT_EQ(*thinking_off, + "<|begin▁of▁sentence|><|User|>Hello<|Assistant|>"); +} + +} // namespace +} // namespace xllm_service diff --git a/xllm_service/chat_template/jinja_chat_template_test.cpp b/tests/xllm_service/chat_template/jinja_chat_template_test.cpp similarity index 93% rename from xllm_service/chat_template/jinja_chat_template_test.cpp rename to tests/xllm_service/chat_template/jinja_chat_template_test.cpp index 80043b4..d683959 100644 --- a/xllm_service/chat_template/jinja_chat_template_test.cpp +++ b/tests/xllm_service/chat_template/jinja_chat_template_test.cpp @@ -13,12 +13,27 @@ See the License for the specific language governing permissions and limitations under the License. ==============================================================================*/ -#include "jinja_chat_template.h" +#include "chat_template/jinja_chat_template.h" #include +#include + +#include "chat_template/chat_template.h" + namespace xllm_service { +TEST(JinjaChatTemplate, EncodeAddsSpecialTokensViaBase) { + TokenizerArgs args; + args.chat_template("{{ messages[0]['content'] }}"); + args.bos_token(""); + args.eos_token(""); + + std::unique_ptr tmpl = + std::make_unique(args); + EXPECT_TRUE(tmpl->encode_add_special_tokens()); +} + TEST(JinjaChatTemplate, OpenChatModel) { // clang-format off const std::string template_str = diff --git a/tests/xllm_service/chat_template/model_type_test.cpp b/tests/xllm_service/chat_template/model_type_test.cpp new file mode 100644 index 0000000..996a498 --- /dev/null +++ b/tests/xllm_service/chat_template/model_type_test.cpp @@ -0,0 +1,69 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "chat_template/model_type.h" + +#include + +#include +#include + +namespace xllm_service { +namespace { + +// Writes `content` to config.json in a fresh temp dir and returns that dir. +std::string make_model_dir_with_config(const std::string& content) { + const auto dir = std::filesystem::temp_directory_path() / + ("model_type_test_" + + std::to_string(reinterpret_cast(&content)) + + std::to_string(std::rand())); + std::filesystem::create_directories(dir); + std::ofstream(dir / "config.json") << content; + return dir.string(); +} + +TEST(SelectChatTemplateKind, DeepseekV4SelectsCppTemplate) { + EXPECT_EQ(select_chat_template_kind("deepseek_v4"), + ChatTemplateKind::kDeepseekV4Cpp); +} + +TEST(SelectChatTemplateKind, UnknownModelTypeFallsBackToJinja) { + EXPECT_EQ(select_chat_template_kind("qwen3"), ChatTemplateKind::kJinja); +} + +TEST(SelectChatTemplateKind, MissingModelTypeFallsBackToJinja) { + EXPECT_EQ(select_chat_template_kind(std::nullopt), ChatTemplateKind::kJinja); +} + +TEST(LoadModelType, ReadsModelTypeFromConfigJson) { + const auto dir = + make_model_dir_with_config(R"({"model_type": "deepseek_v4"})"); + EXPECT_EQ(load_model_type(dir), "deepseek_v4"); +} + +TEST(LoadModelType, MissingConfigJsonReturnsNullopt) { + const auto dir = std::filesystem::temp_directory_path() / + ("model_type_test_missing_" + std::to_string(std::rand())); + std::filesystem::create_directories(dir); + EXPECT_FALSE(load_model_type(dir.string()).has_value()); +} + +TEST(LoadModelType, ConfigWithoutModelTypeReturnsNullopt) { + const auto dir = make_model_dir_with_config(R"({"hidden_size": 4096})"); + EXPECT_FALSE(load_model_type(dir).has_value()); +} + +} // namespace +} // namespace xllm_service diff --git a/tests/xllm_service/tokenizer/CMakeLists.txt b/tests/xllm_service/tokenizer/CMakeLists.txt new file mode 100644 index 0000000..379e036 --- /dev/null +++ b/tests/xllm_service/tokenizer/CMakeLists.txt @@ -0,0 +1,11 @@ +include(cc_test) + +cc_test( + NAME + tokenizer_test + SRCS + tokenizer_test.cpp + DEPS + :tokenizer + GTest::gtest_main +) diff --git a/tests/xllm_service/tokenizer/tokenizer_test.cpp b/tests/xllm_service/tokenizer/tokenizer_test.cpp new file mode 100644 index 0000000..9f5c01d --- /dev/null +++ b/tests/xllm_service/tokenizer/tokenizer_test.cpp @@ -0,0 +1,59 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "tokenizer/tokenizer.h" + +#include + +namespace xllm_service { +namespace { + +// Records the add_special_tokens flag passed to encode. +class RecordingTokenizer : public Tokenizer { + public: + bool encode(const std::string_view& text, + std::vector* ids, + bool add_special_tokens = true) const override { + last_add_special_tokens = add_special_tokens; + return true; + } + std::string decode(const Slice&, bool) const override { return ""; } + std::optional token_to_id(const std::string_view&) const override { + return std::nullopt; + } + std::string id_to_token(int32_t) const override { return ""; } + size_t vocab_size() const override { return 0; } + std::unique_ptr clone() const override { return nullptr; } + + mutable bool last_add_special_tokens = false; +}; + +} // namespace + +TEST(Tokenizer, EncodeDefaultsToAddingSpecialTokens) { + RecordingTokenizer tokenizer; + std::vector ids; + tokenizer.encode("hi", &ids); + EXPECT_TRUE(tokenizer.last_add_special_tokens); +} + +TEST(Tokenizer, EncodeHonorsExplicitFalse) { + RecordingTokenizer tokenizer; + std::vector ids; + tokenizer.encode("hi", &ids, /*add_special_tokens=*/false); + EXPECT_FALSE(tokenizer.last_add_special_tokens); +} + +} // namespace xllm_service diff --git a/xllm_service/chat_template/CMakeLists.txt b/xllm_service/chat_template/CMakeLists.txt index f5fa59b..6dca416 100644 --- a/xllm_service/chat_template/CMakeLists.txt +++ b/xllm_service/chat_template/CMakeLists.txt @@ -1,10 +1,10 @@ include(cc_library) -include(cc_test) cc_library ( NAME chat_template HDRS + chat_template.h jinja_chat_template.h SRCS jinja_chat_template.cpp @@ -15,13 +15,28 @@ cc_library ( glog::glog ) +cc_library ( + NAME + model_type + HDRS + model_type.h + SRCS + model_type.cpp + DEPS + :common + glog::glog + nlohmann_json::nlohmann_json +) + cc_library ( NAME message_projection HDRS message_projection.h + mm_content_text.h SRCS message_projection.cpp + mm_content_text.cpp INCLUDES ${CMAKE_SOURCE_DIR}/third_party/xllm ${CMAKE_SOURCE_DIR}/third_party/xllm/xllm @@ -34,12 +49,39 @@ cc_library ( ) target_link_libraries(message_projection PUBLIC brpc-static leveldb::leveldb ZLIB::ZLIB protobuf::libprotobuf OpenSSL::SSL OpenSSL::Crypto) -cc_test ( +# Torch is header-only here: xllm::Message pulls in , but the V4 +# template uses no torch symbols, so we add include paths without linking libtorch. +set(TORCH_HEADER_INCLUDE_DIRS "" CACHE STRING "Torch header include dirs") +if(NOT TORCH_HEADER_INCLUDE_DIRS) + execute_process( + COMMAND python -c "import torch,os;p=os.path.dirname(torch.__file__);print(os.path.join(p,'include'));print(os.path.join(p,'include','torch','csrc','api','include'))" + OUTPUT_VARIABLE _torch_include_out + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _torch_include_res) + if(NOT _torch_include_res EQUAL 0) + message(FATAL_ERROR "Failed to locate torch include dirs for DeepSeek V4 cpp chat template") + endif() + string(REPLACE "\n" ";" TORCH_HEADER_INCLUDE_DIRS "${_torch_include_out}") +endif() + +cc_library ( NAME - chat_template_test + deepseek_v4_cpp_chat_template + HDRS + deepseek_v4_cpp_chat_template.h SRCS - jinja_chat_template_test.cpp + deepseek_v4_cpp_chat_template.cpp + ${CMAKE_SOURCE_DIR}/third_party/xllm/xllm/core/framework/chat_template/deepseek_v4_cpp_template.cpp + INCLUDES + ${CMAKE_SOURCE_DIR}/third_party/xllm + ${CMAKE_SOURCE_DIR}/third_party/xllm/xllm + ${CMAKE_SOURCE_DIR}/third_party/xllm/xllm/core + ${TORCH_HEADER_INCLUDE_DIRS} DEPS :chat_template - GTest::gtest_main + :message_projection + glog::glog + nlohmann_json::nlohmann_json + proto_xllm ) +target_link_libraries(deepseek_v4_cpp_chat_template PUBLIC brpc-static leveldb::leveldb ZLIB::ZLIB protobuf::libprotobuf OpenSSL::SSL OpenSSL::Crypto) diff --git a/xllm_service/chat_template/chat_template.h b/xllm_service/chat_template/chat_template.h new file mode 100644 index 0000000..00d37f5 --- /dev/null +++ b/xllm_service/chat_template/chat_template.h @@ -0,0 +1,44 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#pragma once + +#include +#include +#include +#include + +#include "common/types.h" + +namespace xllm_service { + +struct Message; +using ChatMessages = std::vector; + +// Renders messages/tools into a prompt. +class ChatTemplate { + public: + virtual ~ChatTemplate() = default; + + virtual std::optional apply( + const ChatMessages& messages, + const std::vector& json_tools, + const nlohmann::ordered_json& chat_template_kwargs) const = 0; + + // Whether the tokenizer should add special tokens when encoding the prompt. + virtual bool encode_add_special_tokens() const = 0; +}; + +} // namespace xllm_service diff --git a/xllm_service/chat_template/deepseek_v4_cpp_chat_template.cpp b/xllm_service/chat_template/deepseek_v4_cpp_chat_template.cpp new file mode 100644 index 0000000..0d4d61d --- /dev/null +++ b/xllm_service/chat_template/deepseek_v4_cpp_chat_template.cpp @@ -0,0 +1,116 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "chat_template/deepseek_v4_cpp_chat_template.h" + +#include + +#include "chat_template/mm_content_text.h" // pulls in xllm_service::Message +#include "core/common/message.h" +#include "core/common/types.h" +#include "framework/chat_template/deepseek_v4_cpp_template.h" +#include "framework/tokenizer/tokenizer_args.h" + +// (via message.h) defines c10's own LOG macro, but we don't +// link libtorch. Restore glog's LOG macro for our logging. +#include +#undef LOG +#define LOG(severity) COMPACT_GOOGLE_LOG_##severity.stream() + +namespace xllm_service { +namespace { + +// Copies the service TokenizerArgs onto the upstream one (V4 only reads +// bos_token, but we copy all same-named string fields). +xllm::TokenizerArgs to_xllm_tokenizer_args(const TokenizerArgs& args) { + xllm::TokenizerArgs out; + out.chat_template(args.chat_template()); + out.add_bos_token(args.add_bos_token()); + out.add_eos_token(args.add_eos_token()); + out.bos_token(args.bos_token()); + out.eos_token(args.eos_token()); + out.pad_token(args.pad_token()); + out.tokenizer_class(args.tokenizer_class()); + return out; +} + +xllm::Message to_xllm_message(const Message& msg) { + // Flatten vector content to text; upstream get_text_content drops vectors. + std::string content = + std::holds_alternative(msg.content) + ? std::get(msg.content) + : flat_text(std::get(msg.content)); + xllm::Message out(msg.role, content); + + if (msg.reasoning_content.has_value()) { + out.reasoning_content = *msg.reasoning_content; + } + if (msg.tool_calls.has_value()) { + xllm::Message::ToolCallVec calls; + calls.reserve(msg.tool_calls->size()); + for (const auto& call : *msg.tool_calls) { + xllm::Message::ToolCall out_call; + out_call.id = call.id; + out_call.type = call.type; + out_call.function.name = call.function.name; + out_call.function.arguments = call.function.arguments; + calls.push_back(std::move(out_call)); + } + out.tool_calls = std::move(calls); + } + if (!msg.tool_call_id.empty()) { + out.tool_call_id = msg.tool_call_id; + } + return out; +} + +std::vector to_xllm_tools(const std::vector& tools) { + std::vector out; + out.reserve(tools.size()); + for (const auto& tool : tools) { + xllm::JsonTool xllm_tool; + xllm_tool.type = tool.type; + xllm_tool.function.name = tool.function.name; + xllm_tool.function.description = tool.function.description; + xllm_tool.function.parameters = tool.function.parameters; + out.push_back(std::move(xllm_tool)); + } + return out; +} + +} // namespace + +DeepseekV4CppChatTemplate::DeepseekV4CppChatTemplate(const TokenizerArgs& args) + : impl_(std::make_unique( + to_xllm_tokenizer_args(args))) { + LOG(INFO) << "DeepSeek V4 cpp chat template initialized."; +} + +DeepseekV4CppChatTemplate::~DeepseekV4CppChatTemplate() = default; + +std::optional DeepseekV4CppChatTemplate::apply( + const ChatMessages& messages, + const std::vector& json_tools, + const nlohmann::ordered_json& chat_template_kwargs) const { + xllm::ChatMessages xllm_messages; + xllm_messages.reserve(messages.size()); + for (const auto& msg : messages) { + xllm_messages.push_back(to_xllm_message(msg)); + } + return impl_->apply( + xllm_messages, to_xllm_tools(json_tools), chat_template_kwargs); +} + +} // namespace xllm_service diff --git a/xllm_service/chat_template/deepseek_v4_cpp_chat_template.h b/xllm_service/chat_template/deepseek_v4_cpp_chat_template.h new file mode 100644 index 0000000..9255d23 --- /dev/null +++ b/xllm_service/chat_template/deepseek_v4_cpp_chat_template.h @@ -0,0 +1,53 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#pragma once + +#include +#include +#include +#include +#include + +#include "chat_template/chat_template.h" +#include "tokenizer/tokenizer_args.h" + +namespace xllm { +class DeepseekV4CppTemplate; +} // namespace xllm + +namespace xllm_service { + +// Renders DeepSeek V4 prompts via the upstream xllm::DeepseekV4CppTemplate; all +// xllm/torch types are kept out of this header. The V4 template writes BOS/role +// markers itself, so encode_add_special_tokens() is false to avoid a double +// BOS. +class DeepseekV4CppChatTemplate : public ChatTemplate { + public: + explicit DeepseekV4CppChatTemplate(const TokenizerArgs& args); + ~DeepseekV4CppChatTemplate() override; + + bool encode_add_special_tokens() const override { return false; } + + std::optional apply( + const ChatMessages& messages, + const std::vector& json_tools, + const nlohmann::ordered_json& chat_template_kwargs) const override; + + private: + std::unique_ptr impl_; +}; + +} // namespace xllm_service diff --git a/xllm_service/chat_template/jinja_chat_template.h b/xllm_service/chat_template/jinja_chat_template.h index 9579b43..3b29392 100644 --- a/xllm_service/chat_template/jinja_chat_template.h +++ b/xllm_service/chat_template/jinja_chat_template.h @@ -22,6 +22,7 @@ limitations under the License. #include #include +#include "chat_template/chat_template.h" #include "common/types.h" #include "tokenizer/tokenizer_args.h" @@ -80,13 +81,14 @@ struct Message { std::optional tool_calls; std::string tool_call_id; }; -using ChatMessages = std::vector; // A chat template implementation that uses jinja2 as the template engine. -class JinjaChatTemplate { +class JinjaChatTemplate : public ChatTemplate { public: JinjaChatTemplate(const TokenizerArgs& args); + bool encode_add_special_tokens() const override { return true; } + std::optional apply(const ChatMessages& messages) const; std::optional apply( @@ -96,7 +98,7 @@ class JinjaChatTemplate { std::optional apply( const ChatMessages& messages, const std::vector& json_tools, - const nlohmann::ordered_json& chat_template_kwargs) const; + const nlohmann::ordered_json& chat_template_kwargs) const override; // expose this function for testing // apply the template to the values in the json object diff --git a/xllm_service/chat_template/message_projection.cpp b/xllm_service/chat_template/message_projection.cpp index 620a2a8..d0efebe 100644 --- a/xllm_service/chat_template/message_projection.cpp +++ b/xllm_service/chat_template/message_projection.cpp @@ -15,30 +15,9 @@ limitations under the License. #include "chat_template/message_projection.h" -namespace xllm_service { +#include "chat_template/mm_content_text.h" -std::string flat_text(const Message::MMContentVec& blocks) { - std::string text; - bool first = true; - for (const auto& block : blocks) { - if (block.type != "text") { - continue; - } - if (!first) { - text += '\n'; - } - text += block.text; - first = false; - } - return text; -} - -bool needs_content_vec(const Message::MMContentVec& blocks) { - if (blocks.size() > 1) { - return true; - } - return !blocks.empty() && blocks.front().type != "text"; -} +namespace xllm_service { void to_proto(const Message& msg, xllm::proto::ChatMessage* out) { out->set_role(msg.role); diff --git a/xllm_service/chat_template/message_projection.h b/xllm_service/chat_template/message_projection.h index 88603e2..44516f8 100644 --- a/xllm_service/chat_template/message_projection.h +++ b/xllm_service/chat_template/message_projection.h @@ -19,17 +19,10 @@ limitations under the License. #include "chat.pb.h" #include "chat_template/jinja_chat_template.h" +#include "chat_template/mm_content_text.h" namespace xllm_service { -// Flattens an ordered multimodal content vector into the single text string -// that the proto transport carries: only `text` blocks, joined by '\n'. -std::string flat_text(const Message::MMContentVec& blocks); - -// Whether an ordered content vector must be kept as a structured vector on the -// canonical Message (more than one block, or a single non-text block). -bool needs_content_vec(const Message::MMContentVec& blocks); - // Projects a canonical Message onto the lossy proto ChatMessage transport. // Writes into the pre-allocated `out` (arena friendly). Pure projection: it // reproduces, field for field, what the request path used to build by hand. diff --git a/xllm_service/chat_template/mm_content_text.cpp b/xllm_service/chat_template/mm_content_text.cpp new file mode 100644 index 0000000..ff4b5d8 --- /dev/null +++ b/xllm_service/chat_template/mm_content_text.cpp @@ -0,0 +1,43 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "chat_template/mm_content_text.h" + +namespace xllm_service { + +std::string flat_text(const Message::MMContentVec& blocks) { + std::string text; + bool first = true; + for (const auto& block : blocks) { + if (block.type != "text") { + continue; + } + if (!first) { + text += '\n'; + } + text += block.text; + first = false; + } + return text; +} + +bool needs_content_vec(const Message::MMContentVec& blocks) { + if (blocks.size() > 1) { + return true; + } + return !blocks.empty() && blocks.front().type != "text"; +} + +} // namespace xllm_service diff --git a/xllm_service/chat_template/mm_content_text.h b/xllm_service/chat_template/mm_content_text.h new file mode 100644 index 0000000..8425315 --- /dev/null +++ b/xllm_service/chat_template/mm_content_text.h @@ -0,0 +1,31 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#pragma once + +#include + +#include "chat_template/jinja_chat_template.h" + +namespace xllm_service { + +// Torch-free helpers over a multimodal content vector, in a standalone header +// so callers can reuse them without pulling in xllm/torch headers. + +std::string flat_text(const Message::MMContentVec& blocks); + +bool needs_content_vec(const Message::MMContentVec& blocks); + +} // namespace xllm_service diff --git a/xllm_service/chat_template/model_type.cpp b/xllm_service/chat_template/model_type.cpp new file mode 100644 index 0000000..d717ca5 --- /dev/null +++ b/xllm_service/chat_template/model_type.cpp @@ -0,0 +1,39 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "chat_template/model_type.h" + +#include "common/json_reader.h" + +namespace xllm_service { + +ChatTemplateKind select_chat_template_kind( + const std::optional& model_type) { + if (model_type == "deepseek_v4") { + return ChatTemplateKind::kDeepseekV4Cpp; + } + return ChatTemplateKind::kJinja; +} + +std::optional load_model_type( + const std::string& model_weights_path) { + JsonReader reader; + if (!reader.parse(model_weights_path + "/config.json")) { + return std::nullopt; + } + return reader.value("model_type"); +} + +} // namespace xllm_service diff --git a/xllm_service/chat_template/model_type.h b/xllm_service/chat_template/model_type.h new file mode 100644 index 0000000..eb6da88 --- /dev/null +++ b/xllm_service/chat_template/model_type.h @@ -0,0 +1,34 @@ +/* Copyright 2025-2026 The xLLM Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://github.com/jd-opensource/xllm-service/blob/main/LICENSE + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#pragma once + +#include +#include + +namespace xllm_service { + +enum class ChatTemplateKind { + kJinja, + kDeepseekV4Cpp, +}; + +ChatTemplateKind select_chat_template_kind( + const std::optional& model_type); + +std::optional load_model_type( + const std::string& model_weights_path); + +} // namespace xllm_service diff --git a/xllm_service/scheduler/CMakeLists.txt b/xllm_service/scheduler/CMakeLists.txt index eac48ce..1f00afa 100644 --- a/xllm_service/scheduler/CMakeLists.txt +++ b/xllm_service/scheduler/CMakeLists.txt @@ -56,6 +56,8 @@ cc_library( :anthropic_adapter :anthropic_stream_encoder :chat_template + :deepseek_v4_cpp_chat_template + :model_type :common :etcd_client :loadbalance_policy diff --git a/xllm_service/scheduler/scheduler.cpp b/xllm_service/scheduler/scheduler.cpp index f9c4def..c8ca3f3 100644 --- a/xllm_service/scheduler/scheduler.cpp +++ b/xllm_service/scheduler/scheduler.cpp @@ -15,6 +15,8 @@ limitations under the License. #include "scheduler/scheduler.h" +#include "chat_template/deepseek_v4_cpp_chat_template.h" +#include "chat_template/model_type.h" #include "common/metrics.h" #include "common/utils.h" #include "common/xllm/status.h" @@ -42,7 +44,22 @@ Scheduler::Scheduler(const Options& options) : options_(options) { if (options_.default_backend_type() != "vllm") { tokenizer_ = TokenizerFactory::create_tokenizer(options_.tokenizer_path(), &tokenizer_args_); - chat_template_ = std::make_unique(tokenizer_args_); + + // Select the chat template by config.json model_type (jinja by default). + const auto model_type = load_model_type(options_.tokenizer_path()); + switch (select_chat_template_kind(model_type)) { + case ChatTemplateKind::kDeepseekV4Cpp: + chat_template_ = + std::make_unique(tokenizer_args_); + LOG(INFO) << "Selected DeepseekV4CppChatTemplate (model_type=" + << model_type.value_or("") << ")."; + break; + case ChatTemplateKind::kJinja: + chat_template_ = std::make_unique(tokenizer_args_); + LOG(INFO) << "Selected JinjaChatTemplate (model_type=" + << model_type.value_or("") << ")."; + break; + } } const std::string etcd_username = @@ -140,7 +157,14 @@ bool Scheduler::schedule(std::shared_ptr request) { // encode prompt if (!is_vllm && request->prompt.size() != 0) { - if (!get_tls_tokenizer()->encode(request->prompt, &request->token_ids)) { + if (chat_template_ == nullptr) { + LOG(ERROR) << "Chat template has not configured."; + return false; + } + if (!get_tls_tokenizer()->encode( + request->prompt, + &request->token_ids, + chat_template_->encode_add_special_tokens())) { LOG(ERROR) << "Encode prompt failed: " << request->prompt; return false; } diff --git a/xllm_service/scheduler/scheduler.h b/xllm_service/scheduler/scheduler.h index f8c9704..c097011 100644 --- a/xllm_service/scheduler/scheduler.h +++ b/xllm_service/scheduler/scheduler.h @@ -15,6 +15,7 @@ limitations under the License. #pragma once +#include "chat_template/chat_template.h" #include "chat_template/jinja_chat_template.h" #include "common/call_data.h" #include "common/options.h" @@ -108,7 +109,7 @@ class Scheduler final { TokenizerArgs tokenizer_args_; // chat template instance - std::unique_ptr chat_template_; + std::unique_ptr chat_template_; std::shared_ptr etcd_client_; diff --git a/xllm_service/tokenizer/CMakeLists.txt b/xllm_service/tokenizer/CMakeLists.txt index 06ae7d9..52070a8 100644 --- a/xllm_service/tokenizer/CMakeLists.txt +++ b/xllm_service/tokenizer/CMakeLists.txt @@ -1,5 +1,4 @@ include(cc_library) -include(cc_test) add_subdirectory(tokenizers) diff --git a/xllm_service/tokenizer/fast_tokenizer.cpp b/xllm_service/tokenizer/fast_tokenizer.cpp index 2bd2963..ecc79cb 100644 --- a/xllm_service/tokenizer/fast_tokenizer.cpp +++ b/xllm_service/tokenizer/fast_tokenizer.cpp @@ -33,10 +33,11 @@ std::unique_ptr FastTokenizer::clone() const { FastTokenizer::~FastTokenizer() { tokenizers_free(handle_); } bool FastTokenizer::encode(const std::string_view& text, - std::vector* ids) const { + std::vector* ids, + bool add_special_tokens) const { TokenizerEncodeResult result; tokenizers_encode( - handle_, text.data(), text.size(), /*add_special_tokens=*/1, &result); + handle_, text.data(), text.size(), add_special_tokens ? 1 : 0, &result); std::vector ret(result.token_ids, result.token_ids + result.len); *ids = std::move(ret); diff --git a/xllm_service/tokenizer/fast_tokenizer.h b/xllm_service/tokenizer/fast_tokenizer.h index 3475872..253f0e7 100644 --- a/xllm_service/tokenizer/fast_tokenizer.h +++ b/xllm_service/tokenizer/fast_tokenizer.h @@ -28,7 +28,8 @@ class FastTokenizer : public Tokenizer { ~FastTokenizer() override; bool encode(const std::string_view& text, - std::vector* ids) const override; + std::vector* ids, + bool add_special_tokens = true) const override; std::string decode(const Slice& ids, bool skip_special_tokens) const override; diff --git a/xllm_service/tokenizer/sentencepiece_tokenizer.cpp b/xllm_service/tokenizer/sentencepiece_tokenizer.cpp index 23511a6..ac63059 100644 --- a/xllm_service/tokenizer/sentencepiece_tokenizer.cpp +++ b/xllm_service/tokenizer/sentencepiece_tokenizer.cpp @@ -128,7 +128,17 @@ bool SentencePieceTokenizer::encode_internal(const std::string_view& text, } bool SentencePieceTokenizer::encode(const std::string_view& text, - std::vector* ids) const { + std::vector* ids, + bool add_special_tokens) const { + // This tokenizer always prepends prefix tokens and cannot suppress special + // tokens; warn so callers (e.g. the V4 cpp template) that ask to skip them + // know the request is ignored and risk double-BOS. + if (!add_special_tokens) { + LOG_FIRST_N(WARNING, 1) + << "SentencePieceTokenizer ignores add_special_tokens=false; special " + "tokens are always added."; + } + // prepend prefix tokens if exists if (!prefix_token_ids_.empty()) { ids->insert( diff --git a/xllm_service/tokenizer/sentencepiece_tokenizer.h b/xllm_service/tokenizer/sentencepiece_tokenizer.h index 63a158c..1c2fc80 100644 --- a/xllm_service/tokenizer/sentencepiece_tokenizer.h +++ b/xllm_service/tokenizer/sentencepiece_tokenizer.h @@ -33,7 +33,8 @@ class SentencePieceTokenizer : public Tokenizer { const TokenizerArgs& args); bool encode(const std::string_view& text, - std::vector* ids) const override; + std::vector* ids, + bool add_special_tokens = true) const override; std::string decode(const Slice& ids, bool skip_special_tokens) const override; diff --git a/xllm_service/tokenizer/tiktoken_tokenizer.cpp b/xllm_service/tokenizer/tiktoken_tokenizer.cpp index f14a35d..c77379a 100644 --- a/xllm_service/tokenizer/tiktoken_tokenizer.cpp +++ b/xllm_service/tokenizer/tiktoken_tokenizer.cpp @@ -254,7 +254,17 @@ void TiktokenTokenizer::encode_internal(const std::string_view& text, } bool TiktokenTokenizer::encode(const std::string_view& text, - std::vector* ids) const { + std::vector* ids, + bool add_special_tokens) const { + // This tokenizer always prepends prefix tokens and cannot suppress special + // tokens; warn so callers (e.g. the V4 cpp template) that ask to skip them + // know the request is ignored and risk double-BOS. + if (!add_special_tokens) { + LOG_FIRST_N(WARNING, 1) + << "TiktokenTokenizer ignores add_special_tokens=false; special tokens " + "are always added."; + } + // prepend prefix tokens if exists if (!prefix_token_ids_.empty()) { ids->insert( diff --git a/xllm_service/tokenizer/tiktoken_tokenizer.h b/xllm_service/tokenizer/tiktoken_tokenizer.h index 8829201..29b1398 100644 --- a/xllm_service/tokenizer/tiktoken_tokenizer.h +++ b/xllm_service/tokenizer/tiktoken_tokenizer.h @@ -34,7 +34,8 @@ class TiktokenTokenizer : public Tokenizer { const TokenizerArgs& args); bool encode(const std::string_view& text, - std::vector* ids) const override; + std::vector* ids, + bool add_special_tokens = true) const override; std::string decode(const Slice& ids, bool skip_special_tokens) const override; diff --git a/xllm_service/tokenizer/tokenizer.h b/xllm_service/tokenizer/tokenizer.h index abe07e8..806d5cc 100644 --- a/xllm_service/tokenizer/tokenizer.h +++ b/xllm_service/tokenizer/tokenizer.h @@ -30,7 +30,8 @@ class Tokenizer { virtual ~Tokenizer() = default; virtual bool encode(const std::string_view& text, - std::vector* ids) const = 0; + std::vector* ids, + bool add_special_tokens = true) const = 0; virtual std::string decode(const Slice& ids, bool skip_special_tokens) const = 0;