mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-23 08:56:42 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
31
internal/entity/models/huaweicloud_test.go
Normal file
31
internal/entity/models/huaweicloud_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
87
internal/entity/models/qiniu_test.go
Normal file
87
internal/entity/models/qiniu_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]"
|
||||
|
||||
48
internal/entity/models/siliconflow_test.go
Normal file
48
internal/entity/models/siliconflow_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
106
internal/entity/models/tool_call_test.go
Normal file
106
internal/entity/models/tool_call_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user