diff --git a/gr2gw.go b/gr2gw.go index ecd05ef..48372fa 100644 --- a/gr2gw.go +++ b/gr2gw.go @@ -2594,6 +2594,7 @@ type SpaceDiscovery struct { PrimaryModel string `json:"primary_model"` TotalInputs int `json:"total_inputs"` RawTotalInputs int `json:"raw_total_inputs,omitempty"` + RawDefaultInputs []interface{} `json:"raw_default_inputs,omitempty"` ParamMappings []SpaceParamMapping `json:"param_mappings"` DefaultInputs []interface{} `json:"default_inputs"` MessageIsMultimodal bool `json:"message_is_multimodal"` @@ -3215,18 +3216,13 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover // Select protocol isCallV2 := false - hasSnippet := false if bestEndpointInfo != nil && bestEndpointInfo.CodeSnippets != nil { if bashSnippet, ok := bestEndpointInfo.CodeSnippets["bash"].(string); ok && bashSnippet != "" { - hasSnippet = true if strings.Contains(bashSnippet, "/call/v2/") { isCallV2 = true } } } - if !hasSnippet && strings.HasPrefix(discovery.GradioVersion, "6.") { - isCallV2 = true - } if strings.HasPrefix(discovery.GradioVersion, "3.") { @@ -3358,6 +3354,8 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } } } + discovery.RawDefaultInputs = make([]interface{}, len(discovery.DefaultInputs)) + copy(discovery.RawDefaultInputs, discovery.DefaultInputs) // If the endpoint has canonical parameters exposed via /gradio_api/info, // and trailing inputs in bestMatchingDep.Inputs are unexposed server-side state components, @@ -4467,7 +4465,9 @@ func (g *GradioGateway) executePredictCompletion(w http.ResponseWriter, r *http. if disc.RawTotalInputs > len(predictData) { for i := len(predictData); i < disc.RawTotalInputs; i++ { var defVal interface{} - if i < len(disc.DefaultInputs) { + if i < len(disc.RawDefaultInputs) { + defVal = disc.RawDefaultInputs[i] + } else if i < len(disc.DefaultInputs) { defVal = disc.DefaultInputs[i] } predictData = append(predictData, defVal) @@ -4579,7 +4579,9 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re if disc.RawTotalInputs > len(queueData) { for i := len(queueData); i < disc.RawTotalInputs; i++ { var defVal interface{} - if i < len(disc.DefaultInputs) { + if i < len(disc.RawDefaultInputs) { + defVal = disc.RawDefaultInputs[i] + } else if i < len(disc.DefaultInputs) { defVal = disc.DefaultInputs[i] } queueData = append(queueData, defVal) @@ -5036,9 +5038,6 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req payloadMap := map[string]interface{}{ "data": gradioData, } - if !disc.IsHunyuan3 { - payloadMap["session_hash"] = GenerateUUID() - } return json.Marshal(payloadMap) } diff --git a/gr2gw_test.go b/gr2gw_test.go index e072b45..2713d56 100644 --- a/gr2gw_test.go +++ b/gr2gw_test.go @@ -2101,9 +2101,9 @@ func TestInspectSpaceBlocksChatbotStateResolution(t *testing.T) { } } -// TestExecuteCallCompletionSessionHashAndTrailingStateTrim verifies that call requests -// send a valid session_hash and trim unexposed trailing state inputs from the payload. -func TestExecuteCallCompletionSessionHashAndTrailingStateTrim(t *testing.T) { +// TestExecuteCallCompletionTrailingStateTrim verifies that call requests +// omit session_hash (to avoid breaking SSE streams) and trim unexposed trailing state inputs from the payload. +func TestExecuteCallCompletionTrailingStateTrim(t *testing.T) { var receivedSessionHash string var receivedDataLen int @@ -2204,8 +2204,8 @@ func TestExecuteCallCompletionSessionHashAndTrailingStateTrim(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) } - if receivedSessionHash == "" { - t.Errorf("expected non-empty session_hash in call payload") + if receivedSessionHash != "" { + t.Errorf("expected empty session_hash in call payload, got %s", receivedSessionHash) } if receivedDataLen != 1 { t.Errorf("expected call data length 1 (unexposed state trimmed), got %d", receivedDataLen) @@ -2337,6 +2337,132 @@ func TestGradio6CallV2Protocol(t *testing.T) { } } +// TestGradio6WithoutSnippetResolvesToCall verifies that Gradio 6 spaces without code snippets +// resolve protocol call (not call_v2), submit standard data array, and omit session_hash. +func TestGradio6WithoutSnippetResolvesToCall(t *testing.T) { + var callEndpointReached string + var receivedPayload 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{}{ + "/predict": map[string]interface{}{ + "parameters": []map[string]interface{}{ + { + "parameter_name": "message", + "component": "Textbox", + "label": "", + }, + }, + // No code_snippets present + }, + }, + }) + return + } + + if r.URL.Path == "/config" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "version": "6.5.1", + "components": []map[string]interface{}{ + {"id": 11, "type": "textbox", "props": map[string]interface{}{"label": ""}}, + {"id": 15, "type": "state"}, + }, + "dependencies": []map[string]interface{}{ + { + "id": 6, + "api_name": "predict", + "inputs": []int{11, 15}, + "outputs": []int{11, 15}, + "types": map[string]interface{}{"generator": true}, + }, + }, + }) + return + } + + if r.URL.Path == "/gradio_api/call/predict" { + callEndpointReached = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&receivedPayload); 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": "evt-call-651", + }) + return + } + + if r.URL.Path == "/gradio_api/call/predict/evt-call-651" { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + fmt.Fprintf(w, "event: complete\ndata: [\"Hello from standard Gradio 6 call!\"]\n\n") + if flusher != nil { + flusher.Flush() + } + return + } + + http.NotFound(w, r) + })) + defer ts.Close() + + gw := NewGradioGateway(ts.URL, "", 10*time.Second) + + // 1. Verify inspection resolved protocol "call", not "call_v2" + disc, err := InspectSpace(gw.client, ts.URL, DefaultUserAgent) + if err != nil { + t.Fatalf("InspectSpace failed: %v", err) + } + if disc.Protocol != "call" { + t.Errorf("expected Protocol call, got %s", disc.Protocol) + } + if disc.Endpoint != "/predict" { + t.Errorf("expected Endpoint /predict, got %s", disc.Endpoint) + } + if disc.TotalInputs != 1 { + t.Errorf("expected TotalInputs 1 (trimmed trailing state), got %d", disc.TotalInputs) + } + if disc.RawTotalInputs != 2 { + t.Errorf("expected RawTotalInputs 2, got %d", disc.RawTotalInputs) + } + + // 2. Execute chat completion + req := ChatCompletionRequest{ + Model: "default", + Messages: []ChatMessage{ + {Role: "user", Content: "Hello test"}, + }, + } + httpReq := httptest.NewRequest("POST", "/v1/chat/completions", nil) + rec := httptest.NewRecorder() + + err = gw.ExecuteChatCompletion(rec, httpReq, req) + if err != nil { + t.Fatalf("ExecuteChatCompletion failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + if callEndpointReached != "/gradio_api/call/predict" { + t.Errorf("expected call to /gradio_api/call/predict, got %s", callEndpointReached) + } + if _, hasHash := receivedPayload["session_hash"]; hasHash { + t.Errorf("expected no session_hash in call payload") + } + dataSlice, ok := receivedPayload["data"].([]interface{}) + if !ok || len(dataSlice) != 1 || dataSlice[0] != "Hello test" { + t.Errorf("expected data payload [\"Hello test\"], got %v", receivedPayload["data"]) + } +} + // TestGradioQueueProtocol verifies direct queue join and queue data streaming. func TestGradioQueueProtocol(t *testing.T) { var joinReached bool