feat(go-models): expand providers (SiliconFlow, Qiniu, Huawei Cloud, Jina) tool call support (#17241)

## Summary

- Add tool call support for SiliconFlow, Qiniu, Huawei Cloud, and Jina
while consolidating shared OpenAI-compatible helpers used by Aliyun
- Handle streaming tool call deltas and advertise tool support for
eligible provider models
- Add cross-provider tool call tests and enable Qiniu connection
verification through the models endpoint

#16990
This commit is contained in:
Hz_
2026-07-22 21:27:58 +08:00
committed by GitHub
parent 29292e9622
commit 2487d4b8f7
14 changed files with 603 additions and 196 deletions

View File

@@ -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",

View File

@@ -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
}
}
]
}

View File

@@ -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",

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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]"

View File

@@ -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")
}
}

View File

@@ -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)
}
}