Restore hygate parity for Hunyuan3 and fix thinking separation

This commit is contained in:
Luxferre
2026-09-07 15:38:42 +03:00
parent 21c11c5fdd
commit 36bbe2bf86
2 changed files with 499 additions and 93 deletions
+284
View File
@@ -2957,3 +2957,287 @@ func TestMultiTurnToolResponseNotWrappedInQuery(t *testing.T) {
t.Errorf("message input should contain tool response, got:\n%s", msgStr)
}
}
func TestParseGradioStreamOutputHunyuanTuple(t *testing.T) {
// Test Hunyuan 3 4-element tuple during reasoning phase: [content, reasoning, tools, history]
raw1 := `[["", "Thinking through the query...", [], [{"role": "user", "content": "hi"}]]]`
f1 := ParseGradioStreamOutput(raw1)
if !f1.OK {
t.Fatalf("expected f1.OK to be true")
}
if f1.Content != "" {
t.Errorf("expected empty content during reasoning phase, got %q", f1.Content)
}
if f1.Reasoning != "Thinking through the query..." {
t.Errorf("expected reasoning 'Thinking through the query...', got %q", f1.Reasoning)
}
// Test Hunyuan 3 completion with both content and reasoning
raw2 := `[["Hello! How can I help?", "Thinking through the query...", [], [{"role": "user", "content": "hi"}]]]`
f2 := ParseGradioStreamOutput(raw2)
if !f2.OK {
t.Fatalf("expected f2.OK to be true")
}
if f2.Content != "Hello! How can I help?" {
t.Errorf("expected content 'Hello! How can I help?', got %q", f2.Content)
}
if f2.Reasoning != "Thinking through the query..." {
t.Errorf("expected reasoning 'Thinking through the query...', got %q", f2.Reasoning)
}
// Test 2-element tuple [content, reasoning]
raw3 := `[["", "Still thinking..."]]`
f3 := ParseGradioStreamOutput(raw3)
if !f3.OK {
t.Fatalf("expected f3.OK to be true")
}
if f3.Content != "" {
t.Errorf("expected empty content, got %q", f3.Content)
}
if f3.Reasoning != "Still thinking..." {
t.Errorf("expected reasoning 'Still thinking...', got %q", f3.Reasoning)
}
}
func TestGradioDiffDeltaReasoningContentSeparation(t *testing.T) {
// Diff appending to reasoning at path [1]
raw1 := `[[["append", [1], "reasoning delta "], ["append", [3, 1, "reasoning_content"], "reasoning delta "]]]`
f1 := ParseGradioStreamOutput(raw1)
if !f1.OK || !f1.IsDelta {
t.Fatalf("expected f1 to be valid delta frame")
}
if f1.Content != "" {
t.Errorf("expected empty content, got %q", f1.Content)
}
if f1.Reasoning != "reasoning delta " {
t.Errorf("expected reasoning delta 'reasoning delta ', got %q", f1.Reasoning)
}
// Diff appending to content at path [0]
raw2 := `[[["append", [0], "content delta "], ["append", [3, 1, "content"], "content delta "]]]`
f2 := ParseGradioStreamOutput(raw2)
if !f2.OK || !f2.IsDelta {
t.Fatalf("expected f2 to be valid delta frame")
}
if f2.Content != "content delta " {
t.Errorf("expected content delta 'content delta ', got %q", f2.Content)
}
if f2.Reasoning != "" {
t.Errorf("expected empty reasoning, got %q", f2.Reasoning)
}
}
func TestHunyuan3CallAndStreamingNoInterleaving(t *testing.T) {
var callPayloadReceived map[string]interface{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gradio_api/info" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"named_endpoints": map[string]interface{}{
"/chat": map[string]interface{}{
"parameters": []map[string]interface{}{
{"parameter_name": "message", "component": "Api"},
{"parameter_name": "system_prompt", "component": "Api"},
{"parameter_name": "history", "component": "Api"},
{"parameter_name": "think_level", "component": "Api"},
{"parameter_name": "temperature", "component": "Api"},
{"parameter_name": "max_tokens", "component": "Api"},
{"parameter_name": "top_p", "component": "Api"},
{"parameter_name": "preserved_thinking", "component": "Api"},
{"parameter_name": "functions_json_str", "component": "Api"},
},
"code_snippets": map[string]interface{}{
"bash": "curl -X POST http://localhost:7860/gradio_api/call/chat -s -H \"Content-Type: application/json\" -d '{\"data\": [\"...\", \"\", null, \"high\", null, 0, 0, null, \"\"]}' | awk -F'\"' '{ print $4}' | read EVENT_ID; curl -N http://localhost:7860/gradio_api/call/chat/$EVENT_ID",
},
},
},
})
return
}
if r.URL.Path == "/config" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"version": "6.12.0",
"components": []map[string]interface{}{
{"id": 30, "type": "api", "props": map[string]interface{}{"label": "1st"}},
{"id": 31, "type": "api", "props": map[string]interface{}{"label": "2nd"}},
{"id": 32, "type": "api", "props": map[string]interface{}{"label": "3rd"}},
{"id": 33, "type": "api", "props": map[string]interface{}{"label": "4th"}},
{"id": 34, "type": "api", "props": map[string]interface{}{"label": "5th"}},
{"id": 35, "type": "api", "props": map[string]interface{}{"label": "6th"}},
{"id": 36, "type": "api", "props": map[string]interface{}{"label": "7th"}},
{"id": 37, "type": "api", "props": map[string]interface{}{"label": "8th"}},
{"id": 38, "type": "api", "props": map[string]interface{}{"label": "9th"}},
{"id": 39, "type": "api", "props": map[string]interface{}{"label": "out"}},
},
"dependencies": []map[string]interface{}{
{
"id": 8,
"api_name": "chat",
"inputs": []int{30, 31, 32, 33, 34, 35, 36, 37, 38},
"outputs": []int{39},
"types": map[string]interface{}{"generator": true},
},
},
})
return
}
if r.URL.Path == "/gradio_api/call/chat" {
if err := json.NewDecoder(r.Body).Decode(&callPayloadReceived); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"event_id": "hy3-event-999",
})
return
}
if r.URL.Path == "/gradio_api/call/chat/hy3-event-999" {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
// Event 1: Thinking chunk 1
fmt.Fprintf(w, "event: generating\ndata: [[\"\", \"Thinking about the answer.\", [], []]]\n\n")
if flusher != nil {
flusher.Flush()
}
// Event 2: Thinking chunk 2
fmt.Fprintf(w, "event: generating\ndata: [[\"\", \"Thinking about the answer. Planning response.\", [], []]]\n\n")
if flusher != nil {
flusher.Flush()
}
// Event 3: Content start
fmt.Fprintf(w, "event: generating\ndata: [[\"Hello \", \"Thinking about the answer. Planning response.\", [], []]]\n\n")
if flusher != nil {
flusher.Flush()
}
// Event 4: Content complete
fmt.Fprintf(w, "event: complete\ndata: [[\"Hello world!\", \"Thinking about the answer. Planning response.\", [], []]]\n\n")
if flusher != nil {
flusher.Flush()
}
return
}
http.NotFound(w, r)
}))
defer ts.Close()
gw := NewGradioGateway(ts.URL, "", 10*time.Second)
// 1. Verify Space Discovery for Hunyuan 3 space
disc := gw.GetDiscovery(ts.URL, "test-ua")
if !disc.IsHunyuan3 {
t.Errorf("expected IsHunyuan3 to be true")
}
if disc.Protocol != "call" {
t.Errorf("expected Protocol 'call', got %q", disc.Protocol)
}
if disc.ThinkLevelIndex != 3 {
t.Errorf("expected ThinkLevelIndex 3, got %d", disc.ThinkLevelIndex)
}
if disc.PreservedThinkingIndex != 7 {
t.Errorf("expected PreservedThinkingIndex 7, got %d", disc.PreservedThinkingIndex)
}
if disc.FunctionsJSONIndex != 8 {
t.Errorf("expected FunctionsJSONIndex 8, got %d", disc.FunctionsJSONIndex)
}
// 2. Verify Streaming Request: NO interleaving between reasoning and content
reqStream := ChatCompletionRequest{
Model: "hy3",
Stream: true,
Messages: []ChatMessage{
{Role: "user", Content: "Hello"},
},
}
httpReqStream := httptest.NewRequest("POST", "/v1/chat/completions", nil)
recStream := httptest.NewRecorder()
err := gw.ExecuteChatCompletion(recStream, httpReqStream, reqStream)
if err != nil {
t.Fatalf("ExecuteChatCompletion streaming failed: %v", err)
}
// Verify call payload has no session_hash for Hunyuan 3
if _, hasHash := callPayloadReceived["session_hash"]; hasHash {
t.Errorf("session_hash should NOT be present in call payload for Hunyuan 3")
}
var streamedReasoning strings.Builder
var streamedContent strings.Builder
lines := strings.Split(recStream.Body.String(), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data: ") && line != "data: [DONE]" {
var chunk StreamResponse
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &chunk); err == nil {
if len(chunk.Choices) > 0 {
delta := chunk.Choices[0].Delta
if delta.ReasoningContent != "" {
if delta.Content != "" {
t.Errorf("interleaving detected: chunk has both reasoning and content: %+v", delta)
}
streamedReasoning.WriteString(delta.ReasoningContent)
}
if delta.Content != "" {
if strings.Contains(delta.Content, "Thinking") {
t.Errorf("leakage detected: content chunk contains reasoning: %q", delta.Content)
}
streamedContent.WriteString(delta.Content)
}
}
}
}
}
if streamedReasoning.String() != "Thinking about the answer. Planning response." {
t.Errorf("expected full reasoning 'Thinking about the answer. Planning response.', got %q", streamedReasoning.String())
}
if streamedContent.String() != "Hello world!" {
t.Errorf("expected full content 'Hello world!', got %q", streamedContent.String())
}
// 3. Verify Non-Streaming Request: separate content and reasoning_content
reqNonStream := ChatCompletionRequest{
Model: "hy3",
Stream: false,
Messages: []ChatMessage{
{Role: "user", Content: "Hello"},
},
}
httpReqNonStream := httptest.NewRequest("POST", "/v1/chat/completions", nil)
recNonStream := httptest.NewRecorder()
err = gw.ExecuteChatCompletion(recNonStream, httpReqNonStream, reqNonStream)
if err != nil {
t.Fatalf("ExecuteChatCompletion non-streaming failed: %v", err)
}
var nonStreamResp ChatCompletionResponse
if err := json.NewDecoder(recNonStream.Body).Decode(&nonStreamResp); err != nil {
t.Fatalf("failed to decode non-stream response: %v", err)
}
if len(nonStreamResp.Choices) == 0 {
t.Fatalf("expected at least 1 choice")
}
choice := nonStreamResp.Choices[0]
if choice.Message.ReasoningContent != "Thinking about the answer. Planning response." {
t.Errorf("expected reasoning_content 'Thinking about the answer. Planning response.', got %q", choice.Message.ReasoningContent)
}
if choice.Message.GetContentString() != "Hello world!" {
t.Errorf("expected content 'Hello world!', got %q", choice.Message.GetContentString())
}
}