diff --git a/README.md b/README.md index 6c379ee..f5a8bd3 100644 --- a/README.md +++ b/README.md @@ -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.). - **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**: - 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. - Automatically pads hidden/State inputs (e.g. Gradio State components) to prevent backend argument count mismatches. - **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. - **Thinking & Reasoning Token Separation**: - - Detects `...` tags in real time. + - Streams native reasoning chunks as `delta.reasoning_content` in real time. + - Detects `...` tags in real time as fallback for standard spaces. - Separates reasoning into `delta.reasoning_content` (streaming) and `message.reasoning_content` (non-streaming). - Keeps `content` clean without tag leakage. - **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 `` 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"`. - **Built-in SOCKS5 Proxy Client**: diff --git a/gr2gw.go b/gr2gw.go index cbe6c5d..35abeb7 100644 --- a/gr2gw.go +++ b/gr2gw.go @@ -47,7 +47,7 @@ type ModelsResponse struct { } type ToolCallFunction struct { - Name string `json:"name"` + Name string `json:"name,omitempty"` Arguments string `json:"arguments"` } @@ -1214,15 +1214,19 @@ type SpaceDiscovery struct { CleanEndpoint string // e.g. "chat_fn" or "chat" Protocol string // "call", "queue", "predict" TotalInputs int - ParamMappings []SpaceParamMapping - HistoryIndex int // -1 if none - MessageIndex int // index for user message text - SystemIndex int // -1 if none - TempIndex int // -1 if none - MaxTokensIndex int // -1 if none - TopPIndex int // -1 if none - HistoryFormat string // "messages", "pairs", "none" - LastDiscovered time.Time + ParamMappings []SpaceParamMapping + HistoryIndex int // -1 if none + MessageIndex int // index for user message text + SystemIndex int // -1 if none + TempIndex int // -1 if none + MaxTokensIndex 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" + LastDiscovered time.Time } func (d *SpaceDiscovery) GetModelList() []ModelItem { @@ -1279,14 +1283,17 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover CleanEndpoint: "chat_fn", Protocol: "call", TotalInputs: 1, - HistoryIndex: -1, - MessageIndex: 0, - SystemIndex: -1, - TempIndex: -1, - MaxTokensIndex: -1, - TopPIndex: -1, - HistoryFormat: "messages", - LastDiscovered: time.Now(), + HistoryIndex: -1, + MessageIndex: 0, + SystemIndex: -1, + TempIndex: -1, + MaxTokensIndex: -1, + TopPIndex: -1, + ThinkLevelIndex: -1, + FunctionsJSONIndex: -1, + PreservedThinkingIndex: -1, + HistoryFormat: "messages", + LastDiscovered: time.Now(), } // 1. Try fetching /gradio_api/info or /info @@ -1535,6 +1542,12 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover discovery.HistoryIndex = idx } else if strings.Contains(pName, "system") { 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") { discovery.TempIndex = idx } 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 if 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. 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 historyArray []map[string]interface{} @@ -1678,12 +1713,72 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet if len(nonSystem) > 0 { for i := 0; i < len(nonSystem)-1; i++ { m := nonSystem[i] - historyArray = append(historyArray, map[string]interface{}{ - "role": m.Role, - "content": m.GetContentString(), - }) + cStr := m.GetContentString() + item := map[string]interface{}{"role": m.Role} + 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 != "" { lastUserMessage = systemPromptStr } @@ -1750,9 +1845,29 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet 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 req.Temperature != nil { data[disc.TempIndex] = *req.Temperature + } else if disc.IsHunyuan3 { + data[disc.TempIndex] = nil } else { 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 req.TopP != nil { data[disc.TopPIndex] = *req.TopP + } else if disc.IsHunyuan3 { + data[disc.TopPIndex] = 0 } else { 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 } -// ExtractTextFromGradioOutput extracts the assistant text string from Gradio output chunks -func ExtractTextFromGradioOutput(rawJSON string) (string, bool) { +// GradioOutputFrame holds parsed elements from a Gradio SSE output chunk +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{} if err := json.Unmarshal([]byte(rawJSON), &val); err != nil { - return "", false + return frame } switch v := val.(type) { case string: - return v, true + frame.Content = v + frame.OK = true + return frame + case []interface{}: if len(v) == 0 { - return "", false + return frame } - // Check if first element is string - if s, ok := v[0].(string); ok { - return s, true - } - // Check if it's a list of messages: [{"role":..., "content":...}] - if len(v) > 0 { - lastItem := v[len(v)-1] - if m, ok := lastItem.(map[string]interface{}); ok { - if c, ok := m["content"].(string); ok { - return c, true - } - if parts, ok := m["content"].([]interface{}); ok && len(parts) > 0 { - for _, p := range parts { - if pm, ok := p.(map[string]interface{}); ok { - if t, ok := pm["text"].(string); ok { - return t, true + + // Check if v[0] is an inner slice (e.g. Hy3: [[content, reasoning, tool_calls, history]]) + if inner, ok := v[0].([]interface{}); ok { + if len(inner) >= 2 { + s0, ok0 := inner[0].(string) + s1, ok1 := inner[1].(string) + if ok0 && ok1 { + frame.Content = s0 + frame.Reasoning = s1 + if len(inner) >= 3 { + if tcSlice, ok := inner[2].([]interface{}); ok && len(tcSlice) > 0 { + b, err := json.Marshal(tcSlice) + if err == nil { + var tcs []ToolCall + if err := json.Unmarshal(b, &tcs); err == nil { + frame.ToolCalls = tcs + } } } } + frame.OK = true + return frame + } + + // Check if inner is a chat pair: ["user msg", "assistant msg"] + if len(inner) == 2 { + if aStr, ok := inner[1].(string); ok { + frame.Content = aStr + frame.OK = true + return frame + } } } - // Check if it's pairs: [[u1, a1], [u2, a2]] - if pair, ok := lastItem.([]interface{}); ok && len(pair) >= 2 { - if aStr, ok := pair[1].(string); ok { - return aStr, 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 { - return s, true + 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 } @@ -1917,7 +2156,7 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req // 3. Handle Non-Streaming vs Streaming if !req.Stream { reader := bufio.NewReader(streamResp.Body) - var latestFullText string + var latestFrame GradioOutputFrame currentEvent := "" for { @@ -1937,8 +2176,8 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req if currentEvent == "error" { return fmt.Errorf("gradio stream error: %s", dataStr) } - if txt, ok := ExtractTextFromGradioOutput(dataStr); ok { - latestFullText = txt + if frame := ParseGradioStreamOutput(dataStr); frame.OK { + latestFrame = frame } if currentEvent == "complete" { break @@ -1946,14 +2185,23 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req } } - cleanText, reasoning := ExtractThinking(latestFullText) - toolCalls, remainingText, hasTools := DetectToolCalls(cleanText) + cleanText := latestFrame.Content + 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" - var finalContent interface{} = remainingText + var finalContent interface{} = cleanText if hasTools && len(toolCalls) > 0 { finishReason = "tool_calls" - if strings.TrimSpace(remainingText) == "" { + if strings.TrimSpace(cleanText) == "" { finalContent = nil } } @@ -1976,7 +2224,11 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req toolFilter := NewStreamToolCallFilter() reader := bufio.NewReader(streamResp.Body) - var prevText string + var prevContent string + var prevReasoning string + prevToolArgs := make(map[int]string) + nativeReasoningSeen := false + nativeToolCallsSeen := false currentEvent := "" for { @@ -1997,31 +2249,115 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req 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 - if strings.HasPrefix(currentText, prevText) { - delta = currentText[len(prevText):] - } else if prevText == "" { + if strings.HasPrefix(currentText, prevContent) { + delta = currentText[len(prevContent):] + } else if prevContent == "" { delta = currentText } else { delta = currentText } - prevText = currentText + prevContent = currentText if delta != "" { - thinkFilter.Feed(delta, func(contentChunk string) { - toolFilter.Feed(contentChunk, func(cleanChunk string) { - if cleanChunk != "" { - streamer.Content(cleanChunk) - } - }, func(tc ToolCall) { - streamer.ToolCallDelta(tc) - }) - }, func(reasoningChunk string) { - if reasoningChunk != "" { - streamer.Reasoning(reasoningChunk) + 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) { + toolFilter.Feed(contentChunk, func(cleanChunk string) { + if cleanChunk != "" { + streamer.Content(cleanChunk) + } + }, func(tc ToolCall) { + streamer.ToolCallDelta(tc) + }) + }, func(reasoningChunk string) { + if reasoningChunk != "" { + streamer.Reasoning(reasoningChunk) + } + }) + } } } @@ -2031,30 +2367,38 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req } } - // Flush remaining tokens in filters - thinkFilter.Flush(func(contentChunk string) { - toolFilter.Feed(contentChunk, func(cleanChunk string) { + // Flush remaining tokens in filters if used + if !nativeReasoningSeen { + thinkFilter.Flush(func(contentChunk string) { + if !nativeToolCallsSeen { + toolFilter.Feed(contentChunk, func(cleanChunk string) { + if cleanChunk != "" { + streamer.Content(cleanChunk) + } + }, func(tc ToolCall) { + streamer.ToolCallDelta(tc) + }) + } else if contentChunk != "" { + streamer.Content(contentChunk) + } + }, func(reasoningChunk string) { + if reasoningChunk != "" { + streamer.Reasoning(reasoningChunk) + } + }) + } + + if !nativeToolCallsSeen { + toolFilter.Flush(func(cleanChunk string) { if cleanChunk != "" { streamer.Content(cleanChunk) } }, func(tc ToolCall) { streamer.ToolCallDelta(tc) }) - }, func(reasoningChunk string) { - if reasoningChunk != "" { - streamer.Reasoning(reasoningChunk) - } - }) + } - toolFilter.Flush(func(cleanChunk string) { - if cleanChunk != "" { - streamer.Content(cleanChunk) - } - }, func(tc ToolCall) { - streamer.ToolCallDelta(tc) - }) - - if toolFilter.emittedCall { + if nativeToolCallsSeen || toolFilter.emittedCall { streamer.Finish("tool_calls") } else { streamer.Finish("stop") diff --git a/gr2gw_test.go b/gr2gw_test.go index c01399d..aa7ae06 100644 --- a/gr2gw_test.go +++ b/gr2gw_test.go @@ -247,3 +247,248 @@ func TestMockGradioServerCompletion(t *testing.T) { 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) + } +} +