feat(hy3): add native support for tencent hunyuan 3 gradio space

This commit is contained in:
Luxferre
2026-09-07 07:56:39 +03:00
parent d1d7422099
commit 662a4d66b7
3 changed files with 690 additions and 95 deletions
+9 -3
View File
@@ -10,19 +10,25 @@ Default demo space: `https://ghost2513-openai-gpt-oss-120b.hf.space`
- **Zero External Dependencies**: Pure Go standard library (`net/http`, `encoding/json`, `bufio`, etc.). - **Zero External Dependencies**: Pure Go standard library (`net/http`, `encoding/json`, `bufio`, etc.).
- **Automatic Space Introspection**: Dynamically queries `/gradio_api/info`, `/config`, and Hugging Face space metadata to discover models, endpoints, and input parameter mappings. - **Automatic Space Introspection**: Dynamically queries `/gradio_api/info`, `/config`, and Hugging Face space metadata to discover models, endpoints, and input parameter mappings.
- **Native Tencent Hunyuan 3 (`tencent-hy3`) Support**:
- Full native zero-degradation handling for official spaces like `https://tencent-hy3.hf.space`.
- Maps `functions_json_str` natively without polluting the system prompt.
- Preserves multi-turn reasoning content and tool call history in standard OpenAI message schemas.
- Maps `reasoning_effort` (`no_think`, `low`, `high`) directly to `think_level`.
- **Universal Multi-turn Handling**: - **Universal Multi-turn Handling**:
- Automatically formats conversation history into structured inputs when the space supports them. - Automatically formats conversation history into structured inputs when the space supports them.
- Transparently composes multi-turn dialogue (`System`, `User`, `Assistant`) into single prompt inputs when the space only accepts a single message textbox. - Transparently composes multi-turn dialogue (`System`, `User`, `Assistant`) into single prompt inputs when the space only accepts a single message textbox.
- Automatically pads hidden/State inputs (e.g. Gradio State components) to prevent backend argument count mismatches. - Automatically pads hidden/State inputs (e.g. Gradio State components) to prevent backend argument count mismatches.
- **Real-Time Streaming & Accumulation Filter**: - **Real-Time Streaming & Accumulation Filter**:
- Automatically computes token deltas from cumulative or incremental Gradio SSE output streams. - Automatically computes token deltas from cumulative or incremental Gradio SSE output streams (including 2D Hy3 frames `[[content, reasoning, tool_calls, history]]`).
- Emits standards-compliant `chat.completion.chunk` SSE events in real time. - Emits standards-compliant `chat.completion.chunk` SSE events in real time.
- **Thinking & Reasoning Token Separation**: - **Thinking & Reasoning Token Separation**:
- Detects `<think>...</think>` tags in real time. - Streams native reasoning chunks as `delta.reasoning_content` in real time.
- Detects `<think>...</think>` tags in real time as fallback for standard spaces.
- Separates reasoning into `delta.reasoning_content` (streaming) and `message.reasoning_content` (non-streaming). - Separates reasoning into `delta.reasoning_content` (streaming) and `message.reasoning_content` (non-streaming).
- Keeps `content` clean without tag leakage. - Keeps `content` clean without tag leakage.
- **Full Tool Calling & Function Interception**: - **Full Tool Calling & Function Interception**:
- Formats schemas into system prompts with strict function calling instructions. - Formats schemas into native `functions_json_str` (Hy3) or system prompts (standard spaces).
- **`StreamToolCallFilter`**: Stateful sliding-window filter that prevents `<tool_call>` tags from leaking into `delta.content`. Emits structured OpenAI `delta.tool_calls` chunks and sets `finish_reason: "tool_calls"`. - **`StreamToolCallFilter`**: Stateful sliding-window filter that prevents `<tool_call>` tags from leaking into `delta.content`. Emits structured OpenAI `delta.tool_calls` chunks and sets `finish_reason: "tool_calls"`.
- Seamlessly maintains multi-turn context when tool results are submitted back via `role: "tool"`. - Seamlessly maintains multi-turn context when tool results are submitted back via `role: "tool"`.
- **Built-in SOCKS5 Proxy Client**: - **Built-in SOCKS5 Proxy Client**:
+398 -54
View File
@@ -47,7 +47,7 @@ type ModelsResponse struct {
} }
type ToolCallFunction struct { type ToolCallFunction struct {
Name string `json:"name"` Name string `json:"name,omitempty"`
Arguments string `json:"arguments"` Arguments string `json:"arguments"`
} }
@@ -1221,6 +1221,10 @@ type SpaceDiscovery struct {
TempIndex int // -1 if none TempIndex int // -1 if none
MaxTokensIndex int // -1 if none MaxTokensIndex int // -1 if none
TopPIndex int // -1 if none TopPIndex int // -1 if none
ThinkLevelIndex int // -1 if none
FunctionsJSONIndex int // -1 if none
PreservedThinkingIndex int // -1 if none
IsHunyuan3 bool
HistoryFormat string // "messages", "pairs", "none" HistoryFormat string // "messages", "pairs", "none"
LastDiscovered time.Time LastDiscovered time.Time
} }
@@ -1285,6 +1289,9 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
TempIndex: -1, TempIndex: -1,
MaxTokensIndex: -1, MaxTokensIndex: -1,
TopPIndex: -1, TopPIndex: -1,
ThinkLevelIndex: -1,
FunctionsJSONIndex: -1,
PreservedThinkingIndex: -1,
HistoryFormat: "messages", HistoryFormat: "messages",
LastDiscovered: time.Now(), LastDiscovered: time.Now(),
} }
@@ -1535,6 +1542,12 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
discovery.HistoryIndex = idx discovery.HistoryIndex = idx
} else if strings.Contains(pName, "system") { } else if strings.Contains(pName, "system") {
discovery.SystemIndex = idx discovery.SystemIndex = idx
} else if strings.Contains(pName, "think_level") || strings.Contains(pName, "thinking_level") {
discovery.ThinkLevelIndex = idx
} else if strings.Contains(pName, "functions") || strings.Contains(pName, "tools") {
discovery.FunctionsJSONIndex = idx
} else if strings.Contains(pName, "preserved") {
discovery.PreservedThinkingIndex = idx
} else if strings.Contains(pName, "temp") { } else if strings.Contains(pName, "temp") {
discovery.TempIndex = idx discovery.TempIndex = idx
} else if strings.Contains(pName, "token") { } else if strings.Contains(pName, "token") {
@@ -1545,6 +1558,14 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
} }
} }
if discovery.FunctionsJSONIndex != -1 || discovery.ThinkLevelIndex != -1 || strings.Contains(cleanURL, "hy3") || strings.Contains(cleanURL, "hunyuan") {
discovery.IsHunyuan3 = true
discovery.Models = append(discovery.Models, "hy3", "hunyuan3", "tencent/Hy3")
if discovery.PrimaryModel == "gradio-chat" || discovery.PrimaryModel == "" {
discovery.PrimaryModel = "hy3"
}
}
// Ensure total inputs is at least 1 // Ensure total inputs is at least 1
if discovery.TotalInputs < 1 { if discovery.TotalInputs < 1 {
discovery.TotalInputs = 1 discovery.TotalInputs = 1
@@ -1659,7 +1680,21 @@ func (g *GradioGateway) GetDiscovery(spaceURL, userAgent string) *SpaceDiscovery
// BuildGradioPayload packages OpenAI messages and parameters into the target Gradio input array. // BuildGradioPayload packages OpenAI messages and parameters into the target Gradio input array.
func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatCompletionRequest) ([]interface{}, error) { func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatCompletionRequest) ([]interface{}, error) {
transformed, _, _ := TransformMessages(req) var transformed []ChatMessage
if disc.IsHunyuan3 && disc.FunctionsJSONIndex != -1 {
for _, msg := range req.Messages {
transformed = append(transformed, ChatMessage{
Role: msg.Role,
Content: msg.GetContentString(),
ReasoningContent: msg.ReasoningContent,
ToolCalls: msg.ToolCalls,
ToolCallID: msg.ToolCallID,
Name: msg.Name,
})
}
} else {
transformed, _, _ = TransformMessages(req)
}
var systemPromptStr string var systemPromptStr string
var historyArray []map[string]interface{} var historyArray []map[string]interface{}
@@ -1678,12 +1713,72 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet
if len(nonSystem) > 0 { if len(nonSystem) > 0 {
for i := 0; i < len(nonSystem)-1; i++ { for i := 0; i < len(nonSystem)-1; i++ {
m := nonSystem[i] m := nonSystem[i]
historyArray = append(historyArray, map[string]interface{}{ cStr := m.GetContentString()
"role": m.Role, item := map[string]interface{}{"role": m.Role}
"content": m.GetContentString(), switch m.Role {
}) case "assistant":
if cStr != "" {
item["content"] = cStr
} else {
item["content"] = nil
}
if m.ReasoningContent != "" {
item["reasoning_content"] = m.ReasoningContent
}
if len(m.ToolCalls) > 0 {
item["tool_calls"] = m.ToolCalls
}
case "tool", "function":
item["role"] = "tool"
item["content"] = cStr
toolID := m.ToolCallID
if toolID == "" {
toolID = m.Name
}
if toolID != "" {
item["tool_call_id"] = toolID
}
if m.Name != "" {
item["name"] = m.Name
}
default:
item["content"] = cStr
}
historyArray = append(historyArray, item)
}
lastMsg := nonSystem[len(nonSystem)-1]
lastContent := lastMsg.GetContentString()
if lastMsg.Role == "tool" || lastMsg.Role == "function" {
toolName := lastMsg.Name
if toolName == "" {
toolName = lastMsg.ToolCallID
}
if disc.IsHunyuan3 {
toolItem := map[string]interface{}{
"role": "tool",
"content": lastContent,
}
if lastMsg.ToolCallID != "" {
toolItem["tool_call_id"] = lastMsg.ToolCallID
} else if toolName != "" {
toolItem["tool_call_id"] = toolName
}
if lastMsg.Name != "" {
toolItem["name"] = lastMsg.Name
}
historyArray = append(historyArray, toolItem)
lastUserMessage = "Please proceed based on the tool results."
} else {
if toolName != "" {
lastUserMessage = fmt.Sprintf("Tool result for %s: %s", toolName, lastContent)
} else {
lastUserMessage = lastContent
}
}
} else {
lastUserMessage = lastContent
} }
lastUserMessage = nonSystem[len(nonSystem)-1].GetContentString()
} else if systemPromptStr != "" { } else if systemPromptStr != "" {
lastUserMessage = systemPromptStr lastUserMessage = systemPromptStr
} }
@@ -1750,9 +1845,29 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet
data[disc.SystemIndex] = systemPromptStr data[disc.SystemIndex] = systemPromptStr
} }
if disc.ThinkLevelIndex >= 0 && disc.ThinkLevelIndex < len(data) {
thinkLevel := "high"
if req.ReasoningEffort != "" {
effort := strings.ToLower(req.ReasoningEffort)
switch effort {
case "none", "off", "no_think", "0":
thinkLevel = "no_think"
case "low", "1":
thinkLevel = "low"
case "medium", "high", "2", "3":
thinkLevel = "high"
default:
thinkLevel = effort
}
}
data[disc.ThinkLevelIndex] = thinkLevel
}
if disc.TempIndex >= 0 && disc.TempIndex < len(data) { if disc.TempIndex >= 0 && disc.TempIndex < len(data) {
if req.Temperature != nil { if req.Temperature != nil {
data[disc.TempIndex] = *req.Temperature data[disc.TempIndex] = *req.Temperature
} else if disc.IsHunyuan3 {
data[disc.TempIndex] = nil
} else { } else {
data[disc.TempIndex] = 0.7 data[disc.TempIndex] = 0.7
} }
@@ -1765,64 +1880,188 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet
if disc.TopPIndex >= 0 && disc.TopPIndex < len(data) { if disc.TopPIndex >= 0 && disc.TopPIndex < len(data) {
if req.TopP != nil { if req.TopP != nil {
data[disc.TopPIndex] = *req.TopP data[disc.TopPIndex] = *req.TopP
} else if disc.IsHunyuan3 {
data[disc.TopPIndex] = 0
} else { } else {
data[disc.TopPIndex] = 1.0 data[disc.TopPIndex] = 1.0
} }
} }
if disc.FunctionsJSONIndex >= 0 && disc.FunctionsJSONIndex < len(data) {
functionsJSONStr := ""
if len(req.Tools) > 0 {
b, err := json.Marshal(req.Tools)
if err == nil {
functionsJSONStr = string(b)
}
}
data[disc.FunctionsJSONIndex] = functionsJSONStr
}
return data, nil return data, nil
} }
// ExtractTextFromGradioOutput extracts the assistant text string from Gradio output chunks // GradioOutputFrame holds parsed elements from a Gradio SSE output chunk
func ExtractTextFromGradioOutput(rawJSON string) (string, bool) { type GradioOutputFrame struct {
Content string
Reasoning string
ToolCalls []ToolCall
OK bool
}
// ParseGradioStreamOutput extracts structured content, reasoning, and tool calls from Gradio output
func ParseGradioStreamOutput(rawJSON string) GradioOutputFrame {
var frame GradioOutputFrame
var val interface{} var val interface{}
if err := json.Unmarshal([]byte(rawJSON), &val); err != nil { if err := json.Unmarshal([]byte(rawJSON), &val); err != nil {
return "", false return frame
} }
switch v := val.(type) { switch v := val.(type) {
case string: case string:
return v, true frame.Content = v
frame.OK = true
return frame
case []interface{}: case []interface{}:
if len(v) == 0 { if len(v) == 0 {
return "", false return frame
} }
// Check if first element is string
if s, ok := v[0].(string); ok { // Check if v[0] is an inner slice (e.g. Hy3: [[content, reasoning, tool_calls, history]])
return s, true if inner, ok := v[0].([]interface{}); ok {
} if len(inner) >= 2 {
// Check if it's a list of messages: [{"role":..., "content":...}] s0, ok0 := inner[0].(string)
if len(v) > 0 { s1, ok1 := inner[1].(string)
lastItem := v[len(v)-1] if ok0 && ok1 {
if m, ok := lastItem.(map[string]interface{}); ok { frame.Content = s0
if c, ok := m["content"].(string); ok { frame.Reasoning = s1
return c, true if len(inner) >= 3 {
} if tcSlice, ok := inner[2].([]interface{}); ok && len(tcSlice) > 0 {
if parts, ok := m["content"].([]interface{}); ok && len(parts) > 0 { b, err := json.Marshal(tcSlice)
for _, p := range parts { if err == nil {
if pm, ok := p.(map[string]interface{}); ok { var tcs []ToolCall
if t, ok := pm["text"].(string); ok { if err := json.Unmarshal(b, &tcs); err == nil {
return t, true frame.ToolCalls = tcs
} }
} }
} }
} }
frame.OK = true
return frame
} }
// Check if it's pairs: [[u1, a1], [u2, a2]]
if pair, ok := lastItem.([]interface{}); ok && len(pair) >= 2 { // Check if inner is a chat pair: ["user msg", "assistant msg"]
if aStr, ok := pair[1].(string); ok { if len(inner) == 2 {
return aStr, true if aStr, ok := inner[1].(string); ok {
} frame.Content = aStr
} frame.OK = true
} return frame
case map[string]interface{}:
for _, key := range []string{"text", "content", "response", "data", "value"} {
if s, ok := v[key].(string); ok {
return s, true
} }
} }
} }
// Check if inner is a list of chat message maps: [{"role":..., "content":...}, ...]
// or list of pairs: [["u", "a"], ...]
if len(inner) > 0 {
lastItem := inner[len(inner)-1]
if m, ok := lastItem.(map[string]interface{}); ok {
if c, ok := m["content"].(string); ok {
frame.Content = c
frame.OK = true
}
if r, ok := m["reasoning_content"].(string); ok {
frame.Reasoning = r
}
if tcsRaw, ok := m["tool_calls"].([]interface{}); ok && len(tcsRaw) > 0 {
b, err := json.Marshal(tcsRaw)
if err == nil {
var tcs []ToolCall
if err := json.Unmarshal(b, &tcs); err == nil {
frame.ToolCalls = tcs
}
}
}
if frame.OK {
return frame
}
} else if pair, ok := lastItem.([]interface{}); ok && len(pair) >= 2 {
if aStr, ok := pair[1].(string); ok {
frame.Content = aStr
frame.OK = true
return frame
}
}
}
}
// Check if v[0] is string (standard single output e.g. ["content", null])
if s, ok := v[0].(string); ok {
frame.Content = s
frame.OK = true
return frame
}
// Check if v is a flat list of messages: [{"role": "assistant", ...}]
lastItem := v[len(v)-1]
if m, ok := lastItem.(map[string]interface{}); ok {
if c, ok := m["content"].(string); ok {
frame.Content = c
frame.OK = true
}
if r, ok := m["reasoning_content"].(string); ok {
frame.Reasoning = r
}
if tcsRaw, ok := m["tool_calls"].([]interface{}); ok && len(tcsRaw) > 0 {
b, err := json.Marshal(tcsRaw)
if err == nil {
var tcs []ToolCall
if err := json.Unmarshal(b, &tcs); err == nil {
frame.ToolCalls = tcs
}
}
}
if frame.OK {
return frame
}
}
case map[string]interface{}:
for _, key := range []string{"text", "content", "response", "data", "value"} {
if s, ok := v[key].(string); ok {
frame.Content = s
frame.OK = true
break
}
}
if r, ok := v["reasoning_content"].(string); ok {
frame.Reasoning = r
} else if r, ok := v["reasoning"].(string); ok {
frame.Reasoning = r
}
if tcsRaw, ok := v["tool_calls"].([]interface{}); ok && len(tcsRaw) > 0 {
b, err := json.Marshal(tcsRaw)
if err == nil {
var tcs []ToolCall
if err := json.Unmarshal(b, &tcs); err == nil {
frame.ToolCalls = tcs
}
}
}
if frame.OK {
return frame
}
}
return frame
}
// ExtractTextFromGradioOutput extracts the assistant text string from Gradio output chunks (compatibility wrapper)
func ExtractTextFromGradioOutput(rawJSON string) (string, bool) {
frame := ParseGradioStreamOutput(rawJSON)
if frame.OK {
return frame.Content, true
}
return "", false return "", false
} }
@@ -1917,7 +2156,7 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
// 3. Handle Non-Streaming vs Streaming // 3. Handle Non-Streaming vs Streaming
if !req.Stream { if !req.Stream {
reader := bufio.NewReader(streamResp.Body) reader := bufio.NewReader(streamResp.Body)
var latestFullText string var latestFrame GradioOutputFrame
currentEvent := "" currentEvent := ""
for { for {
@@ -1937,8 +2176,8 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
if currentEvent == "error" { if currentEvent == "error" {
return fmt.Errorf("gradio stream error: %s", dataStr) return fmt.Errorf("gradio stream error: %s", dataStr)
} }
if txt, ok := ExtractTextFromGradioOutput(dataStr); ok { if frame := ParseGradioStreamOutput(dataStr); frame.OK {
latestFullText = txt latestFrame = frame
} }
if currentEvent == "complete" { if currentEvent == "complete" {
break break
@@ -1946,14 +2185,23 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
} }
} }
cleanText, reasoning := ExtractThinking(latestFullText) cleanText := latestFrame.Content
toolCalls, remainingText, hasTools := DetectToolCalls(cleanText) reasoning := latestFrame.Reasoning
toolCalls := latestFrame.ToolCalls
hasTools := len(toolCalls) > 0
if reasoning == "" {
cleanText, reasoning = ExtractThinking(cleanText)
}
if !hasTools {
toolCalls, cleanText, hasTools = DetectToolCalls(cleanText)
}
finishReason := "stop" finishReason := "stop"
var finalContent interface{} = remainingText var finalContent interface{} = cleanText
if hasTools && len(toolCalls) > 0 { if hasTools && len(toolCalls) > 0 {
finishReason = "tool_calls" finishReason = "tool_calls"
if strings.TrimSpace(remainingText) == "" { if strings.TrimSpace(cleanText) == "" {
finalContent = nil finalContent = nil
} }
} }
@@ -1976,7 +2224,11 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
toolFilter := NewStreamToolCallFilter() toolFilter := NewStreamToolCallFilter()
reader := bufio.NewReader(streamResp.Body) reader := bufio.NewReader(streamResp.Body)
var prevText string var prevContent string
var prevReasoning string
prevToolArgs := make(map[int]string)
nativeReasoningSeen := false
nativeToolCallsSeen := false
currentEvent := "" currentEvent := ""
for { for {
@@ -1997,18 +2249,101 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
break break
} }
if currentText, ok := ExtractTextFromGradioOutput(dataStr); ok { frame := ParseGradioStreamOutput(dataStr)
if frame.OK {
// 1. Native reasoning handling
if frame.Reasoning != "" || nativeReasoningSeen {
nativeReasoningSeen = true
var deltaReasoning string
if strings.HasPrefix(frame.Reasoning, prevReasoning) {
deltaReasoning = frame.Reasoning[len(prevReasoning):]
} else if prevReasoning == "" {
deltaReasoning = frame.Reasoning
} else {
deltaReasoning = frame.Reasoning
}
prevReasoning = frame.Reasoning
if deltaReasoning != "" {
streamer.Reasoning(deltaReasoning)
}
}
// 2. Native tool calls handling
if len(frame.ToolCalls) > 0 {
nativeToolCallsSeen = true
for idx, tc := range frame.ToolCalls {
prevArgs, started := prevToolArgs[idx]
currArgs := tc.Function.Arguments
idxCopy := idx
if !started {
tcDelta := ToolCall{
Index: &idxCopy,
ID: tc.ID,
Type: tc.Type,
Function: ToolCallFunction{
Name: tc.Function.Name,
Arguments: currArgs,
},
}
streamer.ToolCallDelta(tcDelta)
prevToolArgs[idx] = currArgs
} else if len(currArgs) > len(prevArgs) {
var argDelta string
if strings.HasPrefix(currArgs, prevArgs) {
argDelta = currArgs[len(prevArgs):]
} else {
argDelta = currArgs[len(prevArgs):]
}
if argDelta != "" {
tcDelta := ToolCall{
Index: &idxCopy,
Function: ToolCallFunction{
Arguments: argDelta,
},
}
streamer.ToolCallDelta(tcDelta)
}
prevToolArgs[idx] = currArgs
}
}
}
// 3. Content handling
currentText := frame.Content
var delta string var delta string
if strings.HasPrefix(currentText, prevText) { if strings.HasPrefix(currentText, prevContent) {
delta = currentText[len(prevText):] delta = currentText[len(prevContent):]
} else if prevText == "" { } else if prevContent == "" {
delta = currentText delta = currentText
} else { } else {
delta = currentText delta = currentText
} }
prevText = currentText prevContent = currentText
if delta != "" { if delta != "" {
if nativeReasoningSeen || nativeToolCallsSeen {
if nativeReasoningSeen && nativeToolCallsSeen {
streamer.Content(delta)
} else if nativeReasoningSeen {
toolFilter.Feed(delta, func(cleanChunk string) {
if cleanChunk != "" {
streamer.Content(cleanChunk)
}
}, func(tc ToolCall) {
streamer.ToolCallDelta(tc)
})
} else {
thinkFilter.Feed(delta, func(contentChunk string) {
if contentChunk != "" {
streamer.Content(contentChunk)
}
}, func(reasoningChunk string) {
if reasoningChunk != "" {
streamer.Reasoning(reasoningChunk)
}
})
}
} else {
thinkFilter.Feed(delta, func(contentChunk string) { thinkFilter.Feed(delta, func(contentChunk string) {
toolFilter.Feed(contentChunk, func(cleanChunk string) { toolFilter.Feed(contentChunk, func(cleanChunk string) {
if cleanChunk != "" { if cleanChunk != "" {
@@ -2024,6 +2359,7 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
}) })
} }
} }
}
if currentEvent == "complete" { if currentEvent == "complete" {
break break
@@ -2031,8 +2367,10 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
} }
} }
// Flush remaining tokens in filters // Flush remaining tokens in filters if used
if !nativeReasoningSeen {
thinkFilter.Flush(func(contentChunk string) { thinkFilter.Flush(func(contentChunk string) {
if !nativeToolCallsSeen {
toolFilter.Feed(contentChunk, func(cleanChunk string) { toolFilter.Feed(contentChunk, func(cleanChunk string) {
if cleanChunk != "" { if cleanChunk != "" {
streamer.Content(cleanChunk) streamer.Content(cleanChunk)
@@ -2040,12 +2378,17 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
}, func(tc ToolCall) { }, func(tc ToolCall) {
streamer.ToolCallDelta(tc) streamer.ToolCallDelta(tc)
}) })
} else if contentChunk != "" {
streamer.Content(contentChunk)
}
}, func(reasoningChunk string) { }, func(reasoningChunk string) {
if reasoningChunk != "" { if reasoningChunk != "" {
streamer.Reasoning(reasoningChunk) streamer.Reasoning(reasoningChunk)
} }
}) })
}
if !nativeToolCallsSeen {
toolFilter.Flush(func(cleanChunk string) { toolFilter.Flush(func(cleanChunk string) {
if cleanChunk != "" { if cleanChunk != "" {
streamer.Content(cleanChunk) streamer.Content(cleanChunk)
@@ -2053,8 +2396,9 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req
}, func(tc ToolCall) { }, func(tc ToolCall) {
streamer.ToolCallDelta(tc) streamer.ToolCallDelta(tc)
}) })
}
if toolFilter.emittedCall { if nativeToolCallsSeen || toolFilter.emittedCall {
streamer.Finish("tool_calls") streamer.Finish("tool_calls")
} else { } else {
streamer.Finish("stop") streamer.Finish("stop")
+245
View File
@@ -247,3 +247,248 @@ func TestMockGradioServerCompletion(t *testing.T) {
t.Errorf("expected stream output to contain delta tokens, got:\n%s", streamOutput) t.Errorf("expected stream output to contain delta tokens, got:\n%s", streamOutput)
} }
} }
func TestParseGradioStreamOutput(t *testing.T) {
// 1. Standard 1D Gradio array
frame1 := ParseGradioStreamOutput(`["Hello from 1D", null]`)
if !frame1.OK || frame1.Content != "Hello from 1D" || frame1.Reasoning != "" || len(frame1.ToolCalls) != 0 {
t.Errorf("unexpected frame1: %+v", frame1)
}
// 2. Hy3 2D array frame with reasoning
hy3Raw := `[["Hello answer", "Let me think deeply...", [], [{"role": "user", "content": "hi"}]]]`
frame2 := ParseGradioStreamOutput(hy3Raw)
if !frame2.OK || frame2.Content != "Hello answer" || frame2.Reasoning != "Let me think deeply..." || len(frame2.ToolCalls) != 0 {
t.Errorf("unexpected frame2: %+v", frame2)
}
// 3. Hy3 2D array frame with tool calls
hy3ToolRaw := `[["", "Calling weather tool", [{"id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Tokyo\"}"}}], []]]`
frame3 := ParseGradioStreamOutput(hy3ToolRaw)
if !frame3.OK || frame3.Content != "" || frame3.Reasoning != "Calling weather tool" || len(frame3.ToolCalls) != 1 {
t.Fatalf("unexpected frame3: %+v", frame3)
}
if frame3.ToolCalls[0].ID != "call_abc" || frame3.ToolCalls[0].Function.Name != "get_weather" {
t.Errorf("unexpected tool call in frame3: %+v", frame3.ToolCalls[0])
}
// 4. Chat pairs
pairRaw := `[[["user prompt", "assistant answer"]]]`
frame4 := ParseGradioStreamOutput(pairRaw)
if !frame4.OK || frame4.Content != "assistant answer" {
t.Errorf("unexpected frame4: %+v", frame4)
}
// 5. Messages array
msgRaw := `[[{"role": "assistant", "content": "msg answer", "reasoning_content": "msg think"}]]`
frame5 := ParseGradioStreamOutput(msgRaw)
if !frame5.OK || frame5.Content != "msg answer" || frame5.Reasoning != "msg think" {
t.Errorf("unexpected frame5: %+v", frame5)
}
}
func TestHunyuan3BuildPayload(t *testing.T) {
gw := &GradioGateway{}
disc := &SpaceDiscovery{
TotalInputs: 9,
MessageIndex: 0,
SystemIndex: 1,
HistoryIndex: 2,
ThinkLevelIndex: 3,
TempIndex: 4,
MaxTokensIndex: 5,
TopPIndex: 6,
FunctionsJSONIndex: 8,
IsHunyuan3: true,
}
temp := 0.2
req := ChatCompletionRequest{
Model: "hy3",
ReasoningEffort: "low",
Temperature: &temp,
Tools: []Tool{
{
Type: "function",
Function: map[string]interface{}{
"name": "calc",
},
},
},
Messages: []ChatMessage{
{Role: "system", Content: "Be helpful"},
{Role: "user", Content: "2+2"},
{
Role: "assistant",
ReasoningContent: "Thinking...",
ToolCalls: []ToolCall{
{ID: "c1", Type: "function", Function: ToolCallFunction{Name: "calc", Arguments: `{"expr":"2+2"}`}},
},
},
{Role: "tool", ToolCallID: "c1", Content: "4"},
},
}
data, err := gw.BuildGradioPayload(disc, req)
if err != nil {
t.Fatalf("BuildGradioPayload failed: %v", err)
}
if len(data) != 9 {
t.Fatalf("expected 9 payload items, got %d", len(data))
}
// Message parameter (0): should be prompt continuation since last was tool
if msg, ok := data[0].(string); !ok || msg != "Please proceed based on the tool results." {
t.Errorf("expected continuation prompt, got %v", data[0])
}
// System parameter (1)
if sys, ok := data[1].(string); !ok || sys != "Be helpful" {
t.Errorf("expected 'Be helpful', got %v", data[1])
}
// History parameter (2): should contain all messages including the tool turn
hist, ok := data[2].([]map[string]interface{})
if !ok {
t.Fatalf("expected history slice of maps, got %T", data[2])
}
if len(hist) != 3 {
t.Fatalf("expected 3 history items (user, assistant, tool), got %d", len(hist))
}
if hist[2]["role"] != "tool" || hist[2]["content"] != "4" || hist[2]["tool_call_id"] != "c1" {
t.Errorf("unexpected tool history entry: %+v", hist[2])
}
// ThinkLevel parameter (3)
if data[3] != "low" {
t.Errorf("expected think_level 'low', got %v", data[3])
}
// Temp parameter (4)
if data[4] != 0.2 {
t.Errorf("expected temp 0.2, got %v", data[4])
}
// FunctionsJSON parameter (8)
fnStr, ok := data[8].(string)
if !ok || !strings.Contains(fnStr, "calc") {
t.Errorf("expected functions_json_str to contain 'calc', got %v", data[8])
}
}
func TestHunyuan3MockServerCompletion(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gradio_api/info" {
resp := GradioAPIInfoResponse{
NamedEndpoints: map[string]GradioEndpointInfo{
"/chat": {
Parameters: []GradioParamInfo{
{ParameterName: "message"},
{ParameterName: "system_prompt"},
{ParameterName: "history"},
{ParameterName: "think_level"},
{ParameterName: "temperature"},
{ParameterName: "max_tokens"},
{ParameterName: "top_p"},
{ParameterName: "preserved_thinking"},
{ParameterName: "functions_json_str"},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
if r.URL.Path == "/gradio_api/call/chat" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GradioJoinResponse{EventID: "evt_hy3"})
return
}
if r.URL.Path == "/gradio_api/call/chat/evt_hy3" {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("expected flusher")
}
// Frame 1: Reasoning delta
fmt.Fprintf(w, "event: generating\ndata: [[\"\", \"Reasoning part 1 \", [], []]]\n\n")
flusher.Flush()
// Frame 2: Tool call initiated
fmt.Fprintf(w, "event: generating\ndata: [[\"\", \"Reasoning part 1 and 2\", [{\"id\": \"call_hy3\", \"type\": \"function\", \"function\": {\"name\": \"search\", \"arguments\": \"{\\\"q\\\": \\\"tencent\\\"}\"}}], []]]\n\n")
flusher.Flush()
// Frame 3: Completion
fmt.Fprintf(w, "event: complete\ndata: [[\"\", \"Reasoning part 1 and 2\", [{\"id\": \"call_hy3\", \"type\": \"function\", \"function\": {\"name\": \"search\", \"arguments\": \"{\\\"q\\\": \\\"tencent\\\"}\"}}], []]]\n\n")
flusher.Flush()
return
}
http.NotFound(w, r)
}))
defer ts.Close()
gw := NewGradioGateway(ts.URL, "", 10*time.Second)
// 1. Non-streaming tool call test
reqBody := ChatCompletionRequest{
Model: "hy3",
Messages: []ChatMessage{
{Role: "user", Content: "search for tencent"},
},
Stream: false,
}
b, _ := json.Marshal(reqBody)
httpReq := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewBuffer(b))
httpReq.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
err := gw.ExecuteChatCompletion(rec, httpReq, reqBody)
if err != nil {
t.Fatalf("unexpected completion error: %v", err)
}
var resp ChatCompletionResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Choices[0].FinishReason != "tool_calls" {
t.Errorf("expected finish_reason 'tool_calls', got %q", resp.Choices[0].FinishReason)
}
if resp.Choices[0].Message.ReasoningContent != "Reasoning part 1 and 2" {
t.Errorf("expected native reasoning, got %q", resp.Choices[0].Message.ReasoningContent)
}
if len(resp.Choices[0].Message.ToolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(resp.Choices[0].Message.ToolCalls))
}
if resp.Choices[0].Message.ToolCalls[0].Function.Name != "search" {
t.Errorf("expected function 'search', got %q", resp.Choices[0].Message.ToolCalls[0].Function.Name)
}
// 2. Streaming tool call test
reqBodyStream := reqBody
reqBodyStream.Stream = true
recStream := httptest.NewRecorder()
err = gw.ExecuteChatCompletion(recStream, httpReq, reqBodyStream)
if err != nil {
t.Fatalf("unexpected streaming error: %v", err)
}
streamOut := recStream.Body.String()
if !strings.Contains(streamOut, "reasoning_content") {
t.Errorf("expected stream to contain reasoning_content, got:\n%s", streamOut)
}
if !strings.Contains(streamOut, "tool_calls") {
t.Errorf("expected stream to contain tool_calls, got:\n%s", streamOut)
}
if !strings.Contains(streamOut, "call_hy3") {
t.Errorf("expected stream to contain tool call ID call_hy3, got:\n%s", streamOut)
}
if !strings.Contains(streamOut, "\"finish_reason\":\"tool_calls\"") {
t.Errorf("expected stream finish_reason tool_calls, got:\n%s", streamOut)
}
}