From c5ea129b4436a80c9572b7a916fa60e4f8a43ecc Mon Sep 17 00:00:00 2001 From: Luxferre Date: Mon, 7 Sep 2026 11:05:20 +0300 Subject: [PATCH] Align default space and Hunyuan 3 behavior with hygate --- README.md | 33 +++++++++++++------ gr2gw.go | 91 ++++++++++++++++++++++++++++++++------------------- gr2gw_test.go | 76 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 149 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 395b873..3e6c92b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ With SOCKS5 proxy: | Flag | Default | Description | |------|---------|-------------| -| `-space`, `-url` | `https://tencent-hy3.hf.space` | Target Gradio space URL | +| `-space`, `-url`, `-endpoint` | `https://tencent-hy3.hf.space` | Target Gradio space URL | +| `-model` | `""` | Exposed model name override (default: auto-detected) | | `-port` | `8080` | Port to listen on | | `-host` | `0.0.0.0` | Host interface to bind to | | `-socks`, `-proxy`, `-socks5` | `""` | SOCKS5 proxy URL (`socks5://user:pass@host:port`) | @@ -105,7 +106,19 @@ Response: "object": "list", "data": [ { - "id": "digital-twin", + "id": "hy3", + "object": "model", + "created": 1788756307, + "owned_by": "gradio" + }, + { + "id": "hunyuan3", + "object": "model", + "created": 1788756307, + "owned_by": "gradio" + }, + { + "id": "tencent/Hy3", "object": "model", "created": 1788756307, "owned_by": "gradio" @@ -120,7 +133,7 @@ Response: curl http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "digital-twin", + "model": "hy3", "messages": [ {"role": "user", "content": "What is the capital of France?"} ] @@ -133,13 +146,13 @@ Response: "id": "chatcmpl-16425f9d-c350-47a1-9a6d-e9ce10871545", "object": "chat.completion", "created": 1788756310, - "model": "digital-twin", + "model": "hy3", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "Paris is the capital of France." + "content": "The capital of France is Paris." }, "finish_reason": "stop" } @@ -158,7 +171,7 @@ Response: curl -N http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "digital-twin", + "model": "hy3", "messages": [ {"role": "user", "content": "Count from 1 to 5."} ], @@ -172,7 +185,7 @@ curl -N http://localhost:8080/v1/chat/completions \ curl http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "digital-twin", + "model": "hy3", "messages": [ {"role": "user", "content": "What is the weather in Tokyo?"} ], @@ -199,7 +212,7 @@ Response: "id": "chatcmpl-32727c62-ef2a-4866-855b-f1c7ec2b8023", "object": "chat.completion", "created": 1788756322, - "model": "digital-twin", + "model": "hy3", "choices": [ { "index": 0, @@ -234,7 +247,7 @@ Response: curl http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "digital-twin", + "model": "hy3", "messages": [ {"role": "user", "content": "What is the weather in Tokyo?"}, { @@ -279,7 +292,7 @@ Response: "id": "chatcmpl-4903ba12-f12b-4cd3-a801-7290bc91a421", "object": "chat.completion", "created": 1788756335, - "model": "digital-twin", + "model": "hy3", "choices": [ { "index": 0, diff --git a/gr2gw.go b/gr2gw.go index 8266089..6c6e21e 100644 --- a/gr2gw.go +++ b/gr2gw.go @@ -28,6 +28,7 @@ var ( DefaultSpaceURL = "https://tencent-hy3.hf.space" DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:153.0) Gecko/20100101 Firefox/153.0" ConfiguredUserAgent string + ConfiguredModelName string ) // --------------------------------------------------------------------------- @@ -1613,6 +1614,16 @@ func (d *SpaceDiscovery) GetModelList() []ModelItem { var items []ModelItem seen := make(map[string]bool) + if ConfiguredModelName != "" { + seen[ConfiguredModelName] = true + items = append(items, ModelItem{ + ID: ConfiguredModelName, + Object: "model", + Created: now, + OwnedBy: "gradio", + }) + } + for _, m := range d.Models { if m != "" && !seen[m] { seen[m] = true @@ -2183,20 +2194,11 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet toolName = lastMsg.ToolCallID } if disc.IsHunyuan3 { - toolItem := map[string]interface{}{ - "role": "tool", - "content": lastContent, + if toolName != "" { + lastUserMessage = fmt.Sprintf("Tool result for %s: %s", toolName, lastContent) + } else { + lastUserMessage = 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\nPlease answer the user's request based on the tool result.", toolName, lastContent) @@ -2393,34 +2395,39 @@ func ParseGradioStreamOutput(rawJSON string) GradioOutputFrame { // 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 { + if len(inner) >= 3 { + s0, _ := inner[0].(string) + s1, _ := inner[1].(string) + frame.Content = s0 + frame.Reasoning = s1 + if inner[2] != nil { + b, err := json.Marshal(inner[2]) + if err == nil { + var tcs []ToolCall + if json.Unmarshal(b, &tcs) == nil && len(tcs) > 0 { + frame.ToolCalls = tcs + } + } + } + frame.OK = true + return frame + } + + 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 - } + if ok1 { + frame.Content = s1 + frame.OK = true + return frame } } @@ -2544,7 +2551,11 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req modelName := req.Model if modelName == "" { - modelName = disc.PrimaryModel + if ConfiguredModelName != "" { + modelName = ConfiguredModelName + } else { + modelName = disc.PrimaryModel + } } gradioData, err := g.BuildGradioPayload(disc, req) @@ -2714,7 +2725,16 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req return fmt.Errorf("upstream Gradio error: %s", errMsg) } if frame := ParseGradioStreamOutput(dataStr); frame.OK { - latestFrame = frame + if frame.Content != "" || latestFrame.Content == "" { + latestFrame.Content = frame.Content + } + if frame.Reasoning != "" || latestFrame.Reasoning == "" { + latestFrame.Reasoning = frame.Reasoning + } + if len(frame.ToolCalls) > 0 { + latestFrame.ToolCalls = frame.ToolCalls + } + latestFrame.OK = true } if currentEvent == "complete" { break @@ -2982,6 +3002,8 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req func main() { spaceFlag := flag.String("space", DefaultSpaceURL, "Target Gradio Space URL") flag.StringVar(spaceFlag, "url", DefaultSpaceURL, "Alias for -space") + flag.StringVar(spaceFlag, "endpoint", DefaultSpaceURL, "Alias for -space") + modelFlag := flag.String("model", "", "Exposed model name override (default: auto-detected)") portFlag := flag.Int("port", 8080, "Gateway HTTP server port") hostFlag := flag.String("host", "0.0.0.0", "Gateway HTTP server host") socksFlag := flag.String("socks", "", "Optional SOCKS5 proxy URL (e.g. socks5://127.0.0.1:1080)") @@ -3007,6 +3029,9 @@ func main() { if *uaFlag != "" { ConfiguredUserAgent = *uaFlag } + if *modelFlag != "" { + ConfiguredModelName = *modelFlag + } gateway := NewGradioGateway(*spaceFlag, *socksFlag, time.Duration(*timeoutFlag)*time.Second) diff --git a/gr2gw_test.go b/gr2gw_test.go index f20fae7..337a6a1 100644 --- a/gr2gw_test.go +++ b/gr2gw_test.go @@ -285,6 +285,19 @@ func TestParseGradioStreamOutput(t *testing.T) { if !frame5.OK || frame5.Content != "msg answer" || frame5.Reasoning != "msg think" { t.Errorf("unexpected frame5: %+v", frame5) } + // 6. Hy3 with null content and reasoning + hy3NullContent := `[[null, "Thinking process...", null]]` + frame6 := ParseGradioStreamOutput(hy3NullContent) + if !frame6.OK || frame6.Content != "" || frame6.Reasoning != "Thinking process..." { + t.Errorf("unexpected frame6: %+v", frame6) + } + + // 7. Hy3 with 3 elements (answer, reasoning, null tool calls) + hy3ThreeElem := `[["Mocked answer", "Mocked reasoning", null]]` + frame7 := ParseGradioStreamOutput(hy3ThreeElem) + if !frame7.OK || frame7.Content != "Mocked answer" || frame7.Reasoning != "Mocked reasoning" || len(frame7.ToolCalls) != 0 { + t.Errorf("unexpected frame7: %+v", frame7) + } } func TestHunyuan3BuildPayload(t *testing.T) { @@ -338,9 +351,9 @@ func TestHunyuan3BuildPayload(t *testing.T) { 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]) + // Message parameter (0): should be tool result prompt matching hygate behavior + if msg, ok := data[0].(string); !ok || msg != "Tool result for c1: 4" { + t.Errorf("expected 'Tool result for c1: 4', got %v", data[0]) } // System parameter (1) @@ -348,16 +361,19 @@ func TestHunyuan3BuildPayload(t *testing.T) { t.Errorf("expected 'Be helpful', got %v", data[1]) } - // History parameter (2): should contain all messages including the tool turn + // History parameter (2): should contain prior turns (user, assistant with tool calls) 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 len(hist) != 2 { + t.Fatalf("expected 2 history items (user, assistant), 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]) + if hist[0]["role"] != "user" || hist[0]["content"] != "2+2" { + t.Errorf("unexpected user history entry: %+v", hist[0]) + } + if hist[1]["role"] != "assistant" || len(hist[1]["tool_calls"].([]ToolCall)) != 1 { + t.Errorf("unexpected assistant history entry: %+v", hist[1]) } // ThinkLevel parameter (3) @@ -375,6 +391,26 @@ func TestHunyuan3BuildPayload(t *testing.T) { if !ok || !strings.Contains(fnStr, "calc") { t.Errorf("expected functions_json_str to contain 'calc', got %v", data[8]) } + + // Test multi-tool turn: 2 tool messages at the end + reqMulti := req + reqMulti.Messages = append(reqMulti.Messages, ChatMessage{Role: "tool", Name: "fetch", Content: "done"}) + dataMulti, err := gw.BuildGradioPayload(disc, reqMulti) + if err != nil { + t.Fatalf("BuildGradioPayload failed on multi-tool: %v", err) + } + // The last tool message is data[0] + if msg, ok := dataMulti[0].(string); !ok || msg != "Tool result for fetch: done" { + t.Errorf("expected 'Tool result for fetch: done', got %v", dataMulti[0]) + } + // The first tool message is in history + histMulti, _ := dataMulti[2].([]map[string]interface{}) + if len(histMulti) != 3 { + t.Fatalf("expected 3 history items (user, assistant, tool 1), got %d", len(histMulti)) + } + if histMulti[2]["role"] != "tool" || histMulti[2]["content"] != "4" { + t.Errorf("unexpected tool 1 in history: %+v", histMulti[2]) + } } func TestHunyuan3MockServerCompletion(t *testing.T) { @@ -1330,3 +1366,27 @@ func TestToolChoiceHandling(t *testing.T) { t.Errorf("expected specific function directive in instruction, got: %s", instrFn) } } + +func TestConfiguredModelName(t *testing.T) { + disc := &SpaceDiscovery{ + PrimaryModel: "hy3", + Models: []string{"hy3", "hunyuan3"}, + } + + // Without override + ConfiguredModelName = "" + list1 := disc.GetModelList() + if len(list1) < 2 || list1[0].ID != "hy3" { + t.Errorf("expected default first model hy3, got %+v", list1) + } + + // With override + ConfiguredModelName = "my-custom-hy3" + defer func() { ConfiguredModelName = "" }() + + list2 := disc.GetModelList() + if len(list2) < 3 || list2[0].ID != "my-custom-hy3" { + t.Errorf("expected first model to be my-custom-hy3, got %+v", list2) + } +} +