diff --git a/server/docs/DS4.md b/server/docs/DS4.md index 3de39b571..0989f656c 100644 --- a/server/docs/DS4.md +++ b/server/docs/DS4.md @@ -22,6 +22,15 @@ DeepSeek V4 Flash is a 43-layer MoE model with: | Indexer | Top-k scorer on ratio-4 layers for compressed KV selection | | HC (Hierarchical Controller) | 4 parallel residual streams, Sinkhorn-normalized combine | +## Tool-call compatibility + +The native DeepSeek V4 renderer requests `` output. The parser +also accepts named bare-JSON fallbacks emitted by compatible checkpoints: +`{"function":"name","parameters":{...}}` and the legacy OpenAI +`{"function_call":{"name":"name","arguments":{...}}}` envelope. Named JSON +calls remain unambiguous when a request supplies more than one tool; ordinary +JSON that does not resolve to an allowed tool is preserved as assistant text. + ## Code Layout | Area | Files | diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 9d6f9f12d..b59d7e2bc 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -665,7 +665,7 @@ json build_props_body(const ServerConfig & config, const bool is_qwen = (config.arch.rfind("qwen", 0) == 0); const bool reasoning_supported = is_qwen; const bool speculative_supported = is_qwen; - const bool tools_supported = is_qwen; + const bool tools_supported = is_qwen || config.arch == "deepseek4"; auto pcs = prefix_cache.stats(); auto pcfs = prefix_cache.full_stats(); diff --git a/server/src/server/sse_emitter.cpp b/server/src/server/sse_emitter.cpp index c3d53bdd3..51ed9ab64 100644 --- a/server/src/server/sse_emitter.cpp +++ b/server/src/server/sse_emitter.cpp @@ -20,13 +20,9 @@ static bool has_request_tools(const json & tools) { return tools.is_array() && !tools.empty(); } -static bool has_single_request_tool(const json & tools) { - return tools.is_array() && tools.size() == 1 && tools[0].is_object(); -} - static bool starts_with_potential_bare_json_tool(const std::string & text, const json & tools) { - if (!has_single_request_tool(tools)) return false; + if (!has_request_tools(tools)) return false; size_t first = text.find_first_not_of(" \t\n\r"); return first != std::string::npos && text[first] == '{'; } diff --git a/server/src/server/tool_parser.cpp b/server/src/server/tool_parser.cpp index 01e2a31c4..7dc8963fc 100644 --- a/server/src/server/tool_parser.cpp +++ b/server/src/server/tool_parser.cpp @@ -560,7 +560,7 @@ static bool parse_complete_parameter_body(const std::string & body, // ─── JSON tool call parser ────────────────────────────────────────────── -// Parse {"name": ..., "arguments": ...} or {"function": {"name": ..., "arguments": ...}} +// Parse the named JSON tool-call envelopes emitted by supported chat models. static bool parse_json_tool_call(const json & obj, std::string & out_name, json & out_args) { if (!obj.is_object()) return false; @@ -588,8 +588,25 @@ static bool parse_json_tool_call(const json & obj, std::string & out_name, json return false; } } - } else if (obj.contains("function") && obj["function"].is_object()) { - const auto & fn = obj["function"]; + } else if (obj.contains("function") && obj["function"].is_string()) { + name = obj["function"].get(); + if (!obj.contains("parameters")) { + return false; + } + if (obj["parameters"].is_object()) { + args = obj["parameters"]; + } else if (obj["parameters"].is_string()) { + try { args = json::parse(obj["parameters"].get()); } + catch (...) { return false; } + } else { + return false; + } + } else if ((obj.contains("function") && obj["function"].is_object()) || + (obj.contains("function_call") && + obj["function_call"].is_object())) { + const auto & fn = obj.contains("function") + ? obj["function"] + : obj["function_call"]; if (!fn.contains("name") || !fn["name"].is_string()) return false; name = fn["name"].get(); if (fn.contains("arguments")) { diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 8373859dc..d14f0034b 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -860,6 +860,51 @@ TEST_CASE(ServerUnitFixture, test_parse_function_call_wrapper) { } } +TEST_CASE(ServerUnitFixture, test_parse_legacy_openai_function_call_json) { + const std::string text = + "{\"function_call\":{\"arguments\":" + "\"{\\\"location\\\":\\\"test-city\\\"}\"," + "\"name\":\"get_weather\"}}"; + const auto result = parse_tool_calls(text, weather_tools()); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + TEST_ASSERT(result.tool_calls[0].name == "get_weather"); + const auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["location"] == "test-city"); + } + TEST_ASSERT(result.cleaned_text.empty()); +} + +TEST_CASE(ServerUnitFixture, test_parse_deepseek_function_parameters_json) { + const std::string text = + "{\"function\":\"get_weather\",\"parameters\":{" + "\"location\":\"test-city\",\"unit\":\"celsius\"}}"; + const auto result = parse_tool_calls(text, weather_tools()); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + TEST_ASSERT(result.tool_calls[0].name == "get_weather"); + const auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["location"] == "test-city"); + TEST_ASSERT(args["unit"] == "celsius"); + } + TEST_ASSERT(result.cleaned_text.empty()); +} + +TEST_CASE(ServerUnitFixture, test_parse_deepseek_function_stringified_parameters_json) { + const std::string text = + "{\"function\":\"get_weather\",\"parameters\":" + "\"{\\\"location\\\":\\\"test-city\\\",\\\"unit\\\":\\\"celsius\\\"}\"}"; + const auto result = parse_tool_calls(text, weather_tools()); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + TEST_ASSERT(result.tool_calls[0].name == "get_weather"); + const auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["location"] == "test-city"); + TEST_ASSERT(args["unit"] == "celsius"); + } + TEST_ASSERT(result.cleaned_text.empty()); +} + TEST_CASE(ServerUnitFixture, test_parse_bare_function_json_with_parameters) { std::string text = "\n" @@ -1440,6 +1485,36 @@ TEST_CASE(ServerUnitFixture, test_emitter_bare_function_json_tool_buffer_detecti TEST_ASSERT(em.accumulated_text().find("bash") == std::string::npos); } +TEST_CASE(ServerUnitFixture, test_emitter_named_json_with_multiple_tools) { + auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_and_bash_tools()); + em.emit_start(); + em.emit_token("{\"function\":\"bash\","); + em.emit_token("\"parameters\":{\"command\":\"pwd\"}}"); + const auto finish = em.emit_finish(20); + + TEST_ASSERT(em.tool_calls().size() == 1); + if (!em.tool_calls().empty()) { + TEST_ASSERT(em.tool_calls()[0].name == "bash"); + const auto args = json::parse(em.tool_calls()[0].arguments); + TEST_ASSERT(args["command"] == "pwd"); + } + TEST_ASSERT(em.accumulated_text().empty()); + const std::string wire = concat(finish); + TEST_ASSERT(wire.find("bash") != std::string::npos); + TEST_ASSERT(wire.find("tool_calls") != std::string::npos); +} + +TEST_CASE(ServerUnitFixture, test_emitter_multi_tool_json_content_is_preserved) { + auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_and_bash_tools()); + em.emit_start(); + em.emit_token("{\"status\":\"ok\"}"); + const auto finish = em.emit_finish(20); + + TEST_ASSERT(em.tool_calls().empty()); + TEST_ASSERT(em.accumulated_text() == "{\"status\":\"ok\"}"); + TEST_ASSERT(concat(finish).find("status") != std::string::npos); +} + TEST_CASE(ServerUnitFixture, test_emitter_anthropic_tool_use_blocks) { @@ -4787,6 +4862,17 @@ TEST_CASE(ServerUnitFixture, test_props_model_card_null_on_family_fallback) { TEST_ASSERT(body["budget_envelope"]["default_max_tokens"].get() == 32768); } +TEST_CASE(ServerUnitFixture, test_props_deepseek4_tool_capability) { + ServerConfig cfg; + cfg.arch = "deepseek4"; + Tokenizer tok; + PrefixCache pc(0, tok); + ToolMemory tm; + const json body = build_props_body(cfg, pc, tm); + + TEST_ASSERT(body["capabilities"]["tools_supported"].get()); +} + TEST_CASE(ServerUnitFixture, test_props_budget_envelope_shape) { // budget_envelope is always present with all five fields and the // expected effort_tiers vocabulary (low|medium|high|x-high|max).