feat(hy3): add native support for tencent hunyuan 3 gradio space
This commit is contained in:
+245
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user