diff --git a/conf/models/huaweicloud.json b/conf/models/huaweicloud.json index 06f47636b5..b6502f14eb 100755 --- a/conf/models/huaweicloud.json +++ b/conf/models/huaweicloud.json @@ -20,6 +20,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -31,6 +34,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -42,6 +48,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": false, "clear_thinking": true @@ -53,6 +62,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": false, "clear_thinking": true @@ -63,7 +75,10 @@ "max_tokens": 131072, "model_types": [ "chat" - ] + ], + "tools": { + "support": true + } }, { "name": "deepseek-r1-250528", @@ -71,6 +86,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -82,6 +100,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -93,6 +114,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -104,6 +128,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -115,6 +142,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -125,7 +155,10 @@ "max_tokens": 131072, "model_types": [ "chat" - ] + ], + "tools": { + "support": true + } }, { "name": "glm-5", @@ -133,6 +166,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -144,6 +180,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -155,7 +194,10 @@ "model_types": [ "chat", "vision" - ] + ], + "tools": { + "support": true + } }, { "name": "bge-m3", diff --git a/conf/models/qiniu.json b/conf/models/qiniu.json index 51ab82f2b3..ab4f2b485b 100644 --- a/conf/models/qiniu.json +++ b/conf/models/qiniu.json @@ -13,6 +13,9 @@ "name": "deepseek/deepseek-v4-flash", "max_tokens": 1048576, "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -22,6 +25,9 @@ "name": "deepseek/deepseek-v4-pro", "max_tokens": 1048576, "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -42,6 +48,9 @@ { "name": "z-ai/glm-5.1", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -50,6 +59,9 @@ { "name": "z-ai/glm-5", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -58,6 +70,9 @@ { "name": "minimax/minimax-m2.7", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -66,6 +81,9 @@ { "name": "minimax/minimax-m2.5", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -74,6 +92,9 @@ { "name": "minimax/minimax-m2.5-highspeed", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -81,12 +102,18 @@ }, { "name": "minimax/minimax-m2.1", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "kimi-k2-thinking", "max_tokens": 262144, "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -94,16 +121,25 @@ }, { "name": "meituan/longcat-flash-lite", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen3-max", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "z-ai/glm-4.6", "max_tokens": 204800, "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -111,11 +147,17 @@ }, { "name": "z-ai/glm-4.7", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "deepseek/deepseek-v3.2-251201", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -124,6 +166,9 @@ { "name": "deepseek/deepseek-v3.2-exp-thinking", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -131,11 +176,17 @@ }, { "name": "deepseek/deepseek-v3.1-terminus", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "deepseek/deepseek-v3.1-terminus-thinking", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -143,15 +194,24 @@ }, { "name": "deepseek-v3.1", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "deepseek-v3-0324", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "deepseek-r1-0528", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -160,6 +220,9 @@ { "name": "deepseek-r1", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -199,6 +262,9 @@ { "name": "doubao-seed-2.0-lite", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -216,6 +282,9 @@ { "name": "doubao-seed-2.0-code", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -224,6 +293,9 @@ { "name": "qwen3-next-80b-a3b-thinking", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -232,6 +304,9 @@ { "name": "qwen3-235b-a22b-thinking-2507", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -240,6 +315,9 @@ { "name": "qwen3-max-2026-01-23", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -247,11 +325,17 @@ }, { "name": "qwen3-next-80b-a3b-instruct", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen3-max-preview", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen-2.5-vl-72b-instruct", @@ -259,11 +343,17 @@ }, { "name": "qwen3-coder-480b-a35b-instruct", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen-turbo", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -271,11 +361,17 @@ }, { "name": "qwen3-235b-a22b-instruct-2507", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen3-32b", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -284,6 +380,9 @@ { "name": "qwen3-30b-a3b", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -291,7 +390,10 @@ }, { "name": "qwen3-235b-a22b", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen-2.5-vl-7b-instruct", @@ -303,12 +405,18 @@ }, { "name": "qwen2.5-max-2025-01-25", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "minimax-m1", "max_tokens": 1048576, "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -317,6 +425,9 @@ { "name": "glm-4.5", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -328,11 +439,17 @@ }, { "name": "deepseek-v3", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen3-30b-a3b-thinking-2507", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -341,6 +458,9 @@ { "name": "glm-4.5-air", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -361,6 +481,9 @@ { "name": "qwen/qwen3.6-plus", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -368,11 +491,17 @@ }, { "name": "deepseek/deepseek-v3.2-exp", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } }, { "name": "qwen/qwen3.7-max", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -390,6 +519,9 @@ { "name": "tencent/hy3-preview", "model_types": ["chat"], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -413,7 +545,10 @@ }, { "name": "qwen3-30b-a3b-instruct-2507", - "model_types": ["chat"] + "model_types": ["chat"], + "tools": { + "support": true + } } ] } \ No newline at end of file diff --git a/conf/models/siliconflow.json b/conf/models/siliconflow.json index f1d6b523b0..c6e368dcb4 100644 --- a/conf/models/siliconflow.json +++ b/conf/models/siliconflow.json @@ -20,6 +20,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -31,6 +34,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -43,6 +49,9 @@ "chat", "vision" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -54,6 +63,9 @@ "model_types": [ "chat" ], + "tools": { + "support": true + }, "thinking": { "default_value": true, "clear_thinking": true @@ -64,21 +76,30 @@ "max_tokens": 32768, "model_types": [ "chat" - ] + ], + "tools": { + "support": true + } }, { "name": "qwen/qwen3.5-4b", "max_tokens": 262144, "model_types": [ "chat" - ] + ], + "tools": { + "support": true + } }, { "name": "tencent/hunyuan-mt-7b", "max_tokens": 32768, "model_types": [ "chat" - ] + ], + "tools": { + "support": true + } }, { "name": "BAAI/bge-reranker-v2-m3", diff --git a/internal/entity/models/aliyun.go b/internal/entity/models/aliyun.go index 74822ef71d..0d4acdcbfd 100644 --- a/internal/entity/models/aliyun.go +++ b/internal/entity/models/aliyun.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "net/http" - "sort" "strings" "ragflow/internal/common" @@ -70,12 +69,10 @@ func (a *AliyunModel) ChatWithMessages(modelName string, messages []Message, api url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Chat) - apiMessages := aliyunChatMessages(messages) - // Build request body reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": false, "temperature": 1, } @@ -168,7 +165,7 @@ func (a *AliyunModel) ChatWithMessages(modelName string, messages []Message, api } answer, hasAnswer := messageMap["content"].(string) - toolCalls := aliyunToolCalls(messageMap) + toolCalls := extractToolCalls(messageMap) if !hasAnswer && len(toolCalls) == 0 { return nil, fmt.Errorf("response contains neither content nor tool calls") } @@ -212,12 +209,10 @@ func (a *AliyunModel) ChatStreamlyWithSender(modelName string, messages []Messag url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Chat) - apiMessages := aliyunChatMessages(messages) - // Build request body with streaming enabled reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": true, "temperature": 1, } @@ -308,14 +303,7 @@ func (a *AliyunModel) ChatStreamlyWithSender(modelName string, messages []Messag return nil } - if toolCalls, ok := delta["tool_calls"].([]interface{}); ok { - for _, rawToolCall := range toolCalls { - toolCall, ok := rawToolCall.(map[string]interface{}) - if !ok { - continue - } - mergeAliyunToolCallDelta(accumulatedToolCalls, toolCall) - } + if accumulateToolCallDeltas(delta, accumulatedToolCalls) { return nil } @@ -342,78 +330,13 @@ func (a *AliyunModel) ChatStreamlyWithSender(modelName string, messages []Messag return fmt.Errorf("aliyun: stream ended before [DONE] or finish_reason") } - if len(accumulatedToolCalls) > 0 && chatModelConfig != nil { - indices := make([]int, 0, len(accumulatedToolCalls)) - for index := range accumulatedToolCalls { - indices = append(indices, index) - } - sort.Ints(indices) - toolCalls := make([]map[string]interface{}, 0, len(indices)) - for _, index := range indices { - toolCalls = append(toolCalls, accumulatedToolCalls[index]) - } - chatModelConfig.ToolCallsResult = &toolCalls - } + setSortedToolCallsResult(chatModelConfig, accumulatedToolCalls) // Send [DONE] marker for OpenAI compatibility endOfStream := "[DONE]" return sender(&endOfStream, nil) } -func mergeAliyunToolCallDelta(accumulated map[int]map[string]interface{}, delta map[string]interface{}) { - indexValue, ok := delta["index"].(float64) - if !ok { - return - } - index := int(indexValue) - toolCall, exists := accumulated[index] - if !exists { - toolCall = map[string]interface{}{"index": indexValue} - accumulated[index] = toolCall - } - - for _, field := range []string{"id", "type"} { - if value, ok := delta[field].(string); ok && value != "" { - toolCall[field] = value - } - } - - functionDelta, ok := delta["function"].(map[string]interface{}) - if !ok { - return - } - function, ok := toolCall["function"].(map[string]interface{}) - if !ok { - function = make(map[string]interface{}) - toolCall["function"] = function - } - if name, ok := functionDelta["name"].(string); ok && name != "" { - function["name"] = name - } - if arguments, ok := functionDelta["arguments"].(string); ok { - currentArguments, _ := function["arguments"].(string) - function["arguments"] = currentArguments + arguments - } -} - -func aliyunChatMessages(messages []Message) []map[string]interface{} { - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessage := map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - if msg.ToolCallID != "" { - apiMessage["tool_call_id"] = msg.ToolCallID - } - if len(msg.ToolCalls) > 0 { - apiMessage["tool_calls"] = msg.ToolCalls - } - apiMessages[i] = apiMessage - } - return apiMessages -} - // aliyunToolChoice prevents qwen-flash from repeatedly issuing another tool call // after a tool result has already been supplied. With "auto", qwen-flash can // keep emitting tool_calls even for a successful result until the ReAct graph @@ -438,20 +361,6 @@ func aliyunToolChoice(modelName string, messages []Message, configured *string) return choice } -func aliyunToolCalls(message map[string]interface{}) []map[string]interface{} { - rawToolCalls, ok := message["tool_calls"].([]interface{}) - if !ok { - return nil - } - toolCalls := make([]map[string]interface{}, 0, len(rawToolCalls)) - for _, rawToolCall := range rawToolCalls { - if toolCall, ok := rawToolCall.(map[string]interface{}); ok { - toolCalls = append(toolCalls, toolCall) - } - } - return toolCalls -} - type aliyunEmbeddingResponse struct { Data []EmbeddingData `json:"data"` Model string `json:"model"` diff --git a/internal/entity/models/base_model.go b/internal/entity/models/base_model.go index f820f6cf88..3775a9579c 100644 --- a/internal/entity/models/base_model.go +++ b/internal/entity/models/base_model.go @@ -240,6 +240,32 @@ func buildChatMessages(messages []Message) []map[string]any { return apiMessages } +// applyChatToolConfig adds OpenAI-compatible tool configuration to a request. +func applyChatToolConfig(reqBody map[string]interface{}, chatConfig *ChatConfig) { + if chatConfig == nil || chatConfig.Tools == nil { + return + } + reqBody["tools"] = chatConfig.Tools + if chatConfig.ToolChoice != nil { + reqBody["tool_choice"] = *chatConfig.ToolChoice + } +} + +// extractToolCalls converts an OpenAI-compatible message's tool calls. +func extractToolCalls(message map[string]interface{}) []map[string]interface{} { + rawToolCalls, ok := message["tool_calls"].([]interface{}) + if !ok { + return nil + } + toolCalls := make([]map[string]interface{}, 0, len(rawToolCalls)) + for _, rawToolCall := range rawToolCalls { + if toolCall, ok := rawToolCall.(map[string]interface{}); ok { + toolCalls = append(toolCalls, toolCall) + } + } + return toolCalls +} + // setSortedToolCallsResult stores accumulated tool calls in index order. func setSortedToolCallsResult(chatConfig *ChatConfig, accumulatedToolCalls map[int]map[string]any) { if chatConfig == nil || len(accumulatedToolCalls) == 0 { diff --git a/internal/entity/models/huaweicloud.go b/internal/entity/models/huaweicloud.go index a4795ad714..8cf75c9eb2 100644 --- a/internal/entity/models/huaweicloud.go +++ b/internal/entity/models/huaweicloud.go @@ -63,17 +63,6 @@ func huaweiCloudRegionForModel(api *APIConfig, modelName string) string { return region } -func huaweiCloudMessages(messages []Message) []map[string]interface{} { - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessages[i] = map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - } - return apiMessages -} - func huaweiCloudIsDeepSeekV4(modelName string) bool { model := strings.ToLower(modelName) return strings.Contains(model, "deepseek-v4-pro") || strings.Contains(model, "deepseek-v4-flash") @@ -200,10 +189,11 @@ func (h *HuaweiCloudModel) ChatWithMessages(modelName string, messages []Message reqb := map[string]interface{}{ "model": huaweiCloudChatModelName(modelName), - "messages": huaweiCloudMessages(messages), + "messages": buildChatMessages(messages), "stream": false, } huaweiCloudApplyChatConfig(reqb, modelName, chatModelConfig) + applyChatToolConfig(reqb, chatModelConfig) jsonData, err := json.Marshal(reqb) if err != nil { @@ -253,8 +243,9 @@ func (h *HuaweiCloudModel) ChatWithMessages(modelName string, messages []Message if !ok { return nil, fmt.Errorf("invalid message format") } - content, ok := messageMap["content"].(string) - if !ok { + toolCalls := extractToolCalls(messageMap) + content, hasContent := messageMap["content"].(string) + if !hasContent && len(toolCalls) == 0 { return nil, fmt.Errorf("invalid content format") } @@ -269,6 +260,7 @@ func (h *HuaweiCloudModel) ChatWithMessages(modelName string, messages []Message return &ChatResponse{ Answer: &content, ReasonContent: &reasonContent, + ToolCalls: toolCalls, }, nil } @@ -301,13 +293,17 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M reqBody := map[string]interface{}{ "model": huaweiCloudChatModelName(modelName), - "messages": huaweiCloudMessages(messages), + "messages": buildChatMessages(messages), "stream": true, } if chatModelConfig != nil && chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } huaweiCloudApplyChatConfig(reqBody, modelName, chatModelConfig) + if chatModelConfig != nil { + chatModelConfig.ToolCallsResult = nil + } + applyChatToolConfig(reqBody, chatModelConfig) jsonData, err := json.Marshal(reqBody) if err != nil { @@ -336,6 +332,7 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M } sawTerminal := false + accumulatedToolCalls := make(map[int]map[string]interface{}) done, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error { if apiErr, ok := event["error"]; ok { return fmt.Errorf("huaweicloud: upstream stream error: %v", apiErr) @@ -349,10 +346,16 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M if !ok { return nil } + if finishReason, ok := firstChoice["finish_reason"].(string); ok && finishReason != "" { + sawTerminal = true + } delta, ok := firstChoice["delta"].(map[string]interface{}) if !ok { return nil } + if accumulateToolCallDeltas(delta, accumulatedToolCalls) { + return nil + } if r, ok := delta["reasoning_content"].(string); ok && r != "" { if err := sender(nil, &r); err != nil { @@ -364,9 +367,6 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M return err } } - if finishReason, ok := firstChoice["finish_reason"].(string); ok && finishReason != "" { - sawTerminal = true - } return nil }) if err != nil { @@ -375,6 +375,7 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M if !done && !sawTerminal { return fmt.Errorf("huaweicloud: stream ended before [DONE] or finish_reason") } + setSortedToolCallsResult(chatModelConfig, accumulatedToolCalls) endOfStream := "[DONE]" if err := sender(&endOfStream, nil); err != nil { diff --git a/internal/entity/models/huaweicloud_test.go b/internal/entity/models/huaweicloud_test.go new file mode 100644 index 0000000000..8c4d59cfc8 --- /dev/null +++ b/internal/entity/models/huaweicloud_test.go @@ -0,0 +1,31 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +// + +package models + +import "testing" + +func TestHuaweiCloudToolCalls(t *testing.T) { + newDriver := func(baseURL string) ModelDriver { + return NewHuaweiCloudModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "v2/chat/completions"}) + } + t.Run("non-streaming", func(t *testing.T) { + testNonStreamingToolCall(t, "deepseek-v4-pro", "/v2/chat/completions", newDriver) + }) + t.Run("streaming", func(t *testing.T) { + testStreamingToolCall(t, "deepseek-v4-pro", "/v2/chat/completions", newDriver) + }) +} diff --git a/internal/entity/models/jina.go b/internal/entity/models/jina.go index b10e803915..d68a0bf595 100644 --- a/internal/entity/models/jina.go +++ b/internal/entity/models/jina.go @@ -73,17 +73,9 @@ func (j *JinaModel) ChatWithMessages(modelName string, messages []Message, apiCo baseURL = strings.TrimSuffix(baseURL, "/") url := fmt.Sprintf("%s/%s", baseURL, j.baseModel.URLSuffix.Chat) - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessages[i] = map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - } - reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": false, } @@ -100,6 +92,7 @@ func (j *JinaModel) ChatWithMessages(modelName string, messages []Message, apiCo if chatModelConfig.Stop != nil { reqBody["stop"] = *chatModelConfig.Stop } + applyChatToolConfig(reqBody, chatModelConfig) } jsonData, err := json.Marshal(reqBody) @@ -153,8 +146,9 @@ func (j *JinaModel) ChatWithMessages(modelName string, messages []Message, apiCo return nil, fmt.Errorf("invalid message format") } - content, ok := messageMap["content"].(string) - if !ok { + toolCalls := extractToolCalls(messageMap) + content, hasContent := messageMap["content"].(string) + if !hasContent && len(toolCalls) == 0 { return nil, fmt.Errorf("invalid content format") } @@ -162,6 +156,7 @@ func (j *JinaModel) ChatWithMessages(modelName string, messages []Message, apiCo return &ChatResponse{ Answer: &content, ReasonContent: &reasonContent, + ToolCalls: toolCalls, }, nil } diff --git a/internal/entity/models/jina_test.go b/internal/entity/models/jina_test.go index 1f40dc1918..26389771d8 100644 --- a/internal/entity/models/jina_test.go +++ b/internal/entity/models/jina_test.go @@ -89,6 +89,12 @@ func TestJinaChatHappyPath(t *testing.T) { } } +func TestJinaChatSupportsToolCalls(t *testing.T) { + testNonStreamingToolCall(t, "jina-vlm", "/chat/completions", func(baseURL string) ModelDriver { + return newJinaForTest(baseURL) + }) +} + func TestJinaChatPropagatesConfig(t *testing.T) { srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { if body["max_tokens"] != float64(128) { diff --git a/internal/entity/models/qiniu.go b/internal/entity/models/qiniu.go index 219fabd4cb..6f6e16cfe4 100644 --- a/internal/entity/models/qiniu.go +++ b/internal/entity/models/qiniu.go @@ -154,16 +154,9 @@ func (q *QiniuModel) ChatWithMessages(modelName string, messages []Message, apiC } url := fmt.Sprintf("%s/%s", resolvedBaseURL, q.baseModel.URLSuffix.Chat) - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessages[i] = map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - } reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": false, "temperature": 1, } @@ -186,6 +179,7 @@ func (q *QiniuModel) ChatWithMessages(modelName string, messages []Message, apiC } applyQiniuThinkingConfig(reqBody, modelName, chatModelConfig) + applyChatToolConfig(reqBody, chatModelConfig) } jsonData, err := json.Marshal(reqBody) @@ -239,15 +233,16 @@ func (q *QiniuModel) ChatWithMessages(modelName string, messages []Message, apiC return nil, fmt.Errorf("invalid message format") } - content, ok := messageMap["content"].(string) - if !ok { + toolCalls := extractToolCalls(messageMap) + content, hasContent := messageMap["content"].(string) + if !hasContent && len(toolCalls) == 0 { return nil, fmt.Errorf("invalid content format") } var reasonContent string if chatModelConfig != nil && chatModelConfig.Thinking != nil && *chatModelConfig.Thinking { reasonContent, ok = messageMap["reasoning_content"].(string) - if !ok { + if !ok && len(toolCalls) == 0 { return nil, fmt.Errorf("invalid reasoning content format") } @@ -259,6 +254,7 @@ func (q *QiniuModel) ChatWithMessages(modelName string, messages []Message, apiC chatResponse := &ChatResponse{ Answer: &content, ReasonContent: &reasonContent, + ToolCalls: toolCalls, } return chatResponse, nil @@ -279,16 +275,9 @@ func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message baseURL := strings.TrimSuffix(resolvedBaseURL, "/") url := fmt.Sprintf("%s/%s", baseURL, q.baseModel.URLSuffix.Chat) - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessages[i] = map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - } reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": true, "temperature": 1, } @@ -311,6 +300,8 @@ func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message } applyQiniuThinkingConfig(reqBody, modelName, chatModelConfig) + chatModelConfig.ToolCallsResult = nil + applyChatToolConfig(reqBody, chatModelConfig) } jsonData, err := json.Marshal(reqBody) if err != nil { @@ -339,7 +330,9 @@ func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - if _, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error { + sawTerminal := false + accumulatedToolCalls := make(map[int]map[string]interface{}) + done, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error { common.Info(fmt.Sprintf("%v", event)) choices, ok := event["choices"].([]interface{}) @@ -350,10 +343,16 @@ func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message if !ok { return nil } + if finishReason, ok := firstChoice["finish_reason"].(string); ok && finishReason != "" { + sawTerminal = true + } delta, ok := firstChoice["delta"].(map[string]interface{}) if !ok { return nil } + if accumulateToolCallDeltas(delta, accumulatedToolCalls) { + return nil + } reasoningContent, ok := delta["reasoning_content"].(string) if ok && reasoningContent != "" { @@ -369,9 +368,14 @@ func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message } return nil - }); err != nil { + }) + if err != nil { return fmt.Errorf("failed to scan response body: %w", err) } + if !done && !sawTerminal { + return fmt.Errorf("qiniu: stream ended before [DONE] or finish_reason") + } + setSortedToolCallsResult(chatModelConfig, accumulatedToolCalls) // Send [DONE] marker for OpenAI compatibility endOfStream := "[DONE]" if err = sender(&endOfStream, nil); err != nil { @@ -465,7 +469,11 @@ func (q *QiniuModel) Balance(apiConfig *APIConfig) (map[string]interface{}, erro } func (q *QiniuModel) CheckConnection(apiConfig *APIConfig) error { - return fmt.Errorf("%s, no such method", q.Name()) + _, err := q.ListModels(apiConfig) + if err != nil { + return err + } + return nil } func (q *QiniuModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { diff --git a/internal/entity/models/qiniu_test.go b/internal/entity/models/qiniu_test.go new file mode 100644 index 0000000000..a0436a927f --- /dev/null +++ b/internal/entity/models/qiniu_test.go @@ -0,0 +1,87 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +// + +package models + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestQiniuToolCalls(t *testing.T) { + newDriver := func(baseURL string) ModelDriver { + return NewQiniuModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "chat/completions"}) + } + t.Run("non-streaming", func(t *testing.T) { + testNonStreamingToolCall(t, "deepseek/deepseek-v4-pro", "/chat/completions", newDriver) + }) + t.Run("streaming", func(t *testing.T) { + testStreamingToolCall(t, "deepseek/deepseek-v4-pro", "/chat/completions", newDriver) + }) +} + +func TestQiniuChatStreamRejectsTruncatedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"partial"}}]}` + "\n\n")) + })) + defer server.Close() + + apiKey := "test-key" + model := NewQiniuModel(map[string]string{"default": server.URL}, URLSuffix{Chat: "chat/completions"}) + err := model.ChatStreamlyWithSender("model", []Message{{Role: "user", Content: "hi"}}, &APIConfig{ApiKey: &apiKey}, &ChatConfig{}, nil, func(_, _ *string) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "stream ended before [DONE] or finish_reason") { + t.Fatalf("error = %v", err) + } +} + +func TestQiniuCheckConnectionUsesListModels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/models" { + t.Errorf("path = %s, want /models", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q, want %q", got, "Bearer test-key") + } + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"deepseek/deepseek-v4-flash"}]}`)) + })) + defer server.Close() + + apiKey := "test-key" + model := NewQiniuModel(map[string]string{"default": server.URL}, URLSuffix{Models: "models"}) + if err := model.CheckConnection(&APIConfig{ApiKey: &apiKey}); err != nil { + t.Fatalf("CheckConnection() error = %v", err) + } +} + +func TestQiniuCheckConnectionPropagatesListModelsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"message":"invalid api key"}`, http.StatusUnauthorized) + })) + defer server.Close() + + apiKey := "invalid-key" + model := NewQiniuModel(map[string]string{"default": server.URL}, URLSuffix{Models: "models"}) + err := model.CheckConnection(&APIConfig{ApiKey: &apiKey}) + if err == nil || !strings.Contains(err.Error(), "status 401") { + t.Fatalf("CheckConnection() error = %v, want status 401", err) + } +} diff --git a/internal/entity/models/siliconflow.go b/internal/entity/models/siliconflow.go index 3abfbc3663..cb6196743f 100644 --- a/internal/entity/models/siliconflow.go +++ b/internal/entity/models/siliconflow.go @@ -82,19 +82,10 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message } url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.Chat) - // Convert messages to the format expected by API - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessages[i] = map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - } - // Build request body reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": false, } @@ -126,6 +117,8 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message // by SiliconFlow, breaking the thinking feature). reqBody["enable_thinking"] = *chatModelConfig.Thinking } + + applyChatToolConfig(reqBody, chatModelConfig) } // Qwen3 family: disable thinking by default (matches Python's @@ -186,8 +179,9 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message return nil, fmt.Errorf("invalid message format") } - content, ok := messageMap["content"].(string) - if !ok { + toolCalls := extractToolCalls(messageMap) + content, hasContent := messageMap["content"].(string) + if !hasContent && len(toolCalls) == 0 { return nil, fmt.Errorf("invalid content format") } @@ -212,6 +206,7 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message chatResponse := &ChatResponse{ Answer: &content, ReasonContent: &reasonContent, + ToolCalls: toolCalls, } return chatResponse, nil @@ -233,19 +228,10 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M } url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.Chat) - // Convert messages to API format - apiMessages := make([]map[string]interface{}, len(messages)) - for i, msg := range messages { - apiMessages[i] = map[string]interface{}{ - "role": msg.Role, - "content": msg.Content, - } - } - // Build request body with streaming enabled reqBody := map[string]interface{}{ "model": modelName, - "messages": apiMessages, + "messages": buildChatMessages(messages), "stream": true, } @@ -273,6 +259,9 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M if chatModelConfig.Stop != nil { reqBody["stop"] = *chatModelConfig.Stop } + + chatModelConfig.ToolCallsResult = nil + applyChatToolConfig(reqBody, chatModelConfig) } // Qwen3 family: disable thinking by default (matches Python's @@ -310,6 +299,7 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M // SSE parsing: read line by line sawTerminal := false + accumulatedToolCalls := make(map[int]map[string]interface{}) done, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error { common.Info(fmt.Sprintf("%v", event)) @@ -322,11 +312,17 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M if !ok { return nil } + if finishReason, ok := firstChoice["finish_reason"].(string); ok && finishReason != "" { + sawTerminal = true + } delta, ok := firstChoice["delta"].(map[string]interface{}) if !ok { return nil } + if accumulateToolCallDeltas(delta, accumulatedToolCalls) { + return nil + } reasoningContent, ok := delta["reasoning_content"].(string) if ok && reasoningContent != "" { @@ -342,11 +338,6 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M } } - finishReason, ok := firstChoice["finish_reason"].(string) - if ok && finishReason != "" { - sawTerminal = true - } - return nil }) if err != nil { @@ -355,6 +346,7 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M if !done && !sawTerminal { return fmt.Errorf("siliconflow: stream ended before [DONE] or finish_reason") } + setSortedToolCallsResult(chatModelConfig, accumulatedToolCalls) // Send [DONE] marker for OpenAI compatibility endOfStream := "[DONE]" diff --git a/internal/entity/models/siliconflow_test.go b/internal/entity/models/siliconflow_test.go new file mode 100644 index 0000000000..28bb872478 --- /dev/null +++ b/internal/entity/models/siliconflow_test.go @@ -0,0 +1,48 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +// + +package models + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSiliconflowToolCalls(t *testing.T) { + newDriver := func(baseURL string) ModelDriver { + return NewSiliconflowModel(map[string]string{"default": baseURL}, URLSuffix{Chat: "chat/completions"}) + } + t.Run("non-streaming", func(t *testing.T) { + testNonStreamingToolCall(t, "Pro/deepseek-ai/DeepSeek-V4-Pro", "/chat/completions", newDriver) + }) + t.Run("streaming", func(t *testing.T) { + testStreamingToolCall(t, "Pro/deepseek-ai/DeepSeek-V4-Pro", "/chat/completions", newDriver) + }) +} + +func TestSiliconflowChatRejectsMissingContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"choices":[{"message":{}}]}`)) + })) + defer server.Close() + + apiKey := "test-key" + model := NewSiliconflowModel(map[string]string{"default": server.URL}, URLSuffix{Chat: "chat/completions"}) + if _, err := model.ChatWithMessages("model", []Message{{Role: "user", Content: "hi"}}, &APIConfig{ApiKey: &apiKey}, nil, nil); err == nil { + t.Fatal("expected missing content error") + } +} diff --git a/internal/entity/models/tool_call_test.go b/internal/entity/models/tool_call_test.go new file mode 100644 index 0000000000..6bedbd911c --- /dev/null +++ b/internal/entity/models/tool_call_test.go @@ -0,0 +1,106 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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. +// + +package models + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func testNonStreamingToolCall(t *testing.T, modelName, path string, newDriver func(string) ModelDriver) { + t.Helper() + var requestBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path { + t.Errorf("path = %q, want %q", r.URL.Path, path) + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Errorf("decode request: %v", err) + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":null,"tool_calls":[{"id":"call-2","type":"function","function":{"name":"retrieval","arguments":"{}"}}]}}]}`)) + })) + defer server.Close() + + apiKey, toolChoice := "test-key", "required" + response, err := newDriver(server.URL).ChatWithMessages( + modelName, + []Message{ + {Role: "assistant", ToolCalls: []map[string]interface{}{{"id": "call-1"}}}, + {Role: "tool", Content: "result", ToolCallID: "call-1"}, + }, + &APIConfig{ApiKey: &apiKey}, + &ChatConfig{Tools: []map[string]interface{}{{"type": "function"}}, ToolChoice: &toolChoice}, + nil, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } + if requestBody["tool_choice"] != toolChoice || requestBody["tools"] == nil { + t.Errorf("tool config = %#v", requestBody) + } + messages, _ := requestBody["messages"].([]interface{}) + toolMessage, _ := messages[1].(map[string]interface{}) + if toolMessage["tool_call_id"] != "call-1" { + t.Errorf("tool message = %#v", toolMessage) + } + if len(response.ToolCalls) != 1 || response.ToolCalls[0]["id"] != "call-2" { + t.Errorf("tool calls = %#v", response.ToolCalls) + } +} + +func testStreamingToolCall(t *testing.T, modelName, path string, newDriver func(string) ModelDriver) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path { + t.Errorf("path = %q, want %q", r.URL.Path, path) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"retrieval","arguments":"{\"query\":\""}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ragflow\"}"}}]},"finish_reason":"tool_calls"}]} + +`)) + })) + defer server.Close() + + apiKey, toolChoice := "test-key", "auto" + config := &ChatConfig{ + Tools: []map[string]interface{}{{"type": "function"}}, + ToolChoice: &toolChoice, + ToolCallsResult: &[]map[string]interface{}{{"id": "stale"}}, + } + err := newDriver(server.URL).ChatStreamlyWithSender( + modelName, + []Message{{Role: "user", Content: "find ragflow"}}, + &APIConfig{ApiKey: &apiKey}, + config, + nil, + func(_, _ *string) error { return nil }, + ) + if err != nil { + t.Fatalf("ChatStreamlyWithSender: %v", err) + } + if config.ToolCallsResult == nil || len(*config.ToolCallsResult) != 1 { + t.Fatalf("tool calls = %#v", config.ToolCallsResult) + } + function, _ := (*config.ToolCallsResult)[0]["function"].(map[string]interface{}) + if function["arguments"] != `{"query":"ragflow"}` { + t.Errorf("function = %#v", function) + } +}