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