diff --git a/gr2gw.go b/gr2gw.go index 1f90363..94b8e52 100644 --- a/gr2gw.go +++ b/gr2gw.go @@ -125,6 +125,7 @@ type ChatCompletionRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` + WebSearch *bool `json:"web_search,omitempty"` } type ChatCompletionResponseChoice struct { @@ -406,6 +407,10 @@ func DoWithFibonacciRetry(client *http.Client, makeReq func() (*http.Request, er if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed { break } + bodyLower := strings.ToLower(string(respBody)) + if strings.Contains(bodyLower, "space is paused") { + break + } if _, ok := extractFailedGeneration(string(respBody)); ok { break } @@ -1853,17 +1858,41 @@ func finalizeOutput(frame GradioOutputFrame) (finalContent interface{}, reasonin return finalContent, reasoning, toolCalls, finishReason } +var ( + thinkTagRegex = regexp.MustCompile(`(?is)([\s\S]*?)(?:<\/think>|$)`) + detailsThinkingRegex = regexp.MustCompile(`(?is)]*)?>\s*\s*(?:thinking|thought|thinking\s+process|reasoning)\s*<\/summary>([\s\S]*?)(?:<\/details>|$)`) +) + func ExtractThinking(content string) (string, string) { + if loc := detailsThinkingRegex.FindStringSubmatchIndex(content); len(loc) >= 4 { + reasoning := content[loc[2]:loc[3]] + rem := content[:loc[0]] + content[loc[1]:] + reasoning = strings.TrimSpace(reasoning) + rem = strings.TrimPrefix(rem, "\n\n") + rem = strings.TrimPrefix(rem, "\n") + rem = strings.TrimSpace(rem) + return rem, reasoning + } if strings.Contains(content, "") && strings.Contains(content, "") { start := strings.Index(content, "") end := strings.Index(content, "") if start < end { reasoning := content[start+len("") : end] rem := content[:start] + content[end+len(""):] + reasoning = strings.TrimSpace(reasoning) rem = strings.TrimPrefix(rem, "\n\n") rem = strings.TrimPrefix(rem, "\n") + rem = strings.TrimSpace(rem) return rem, reasoning } + } else if loc := thinkTagRegex.FindStringSubmatchIndex(content); len(loc) >= 4 { + reasoning := content[loc[2]:loc[3]] + rem := content[:loc[0]] + content[loc[1]:] + reasoning = strings.TrimSpace(reasoning) + rem = strings.TrimPrefix(rem, "\n\n") + rem = strings.TrimPrefix(rem, "\n") + rem = strings.TrimSpace(rem) + return rem, reasoning } return content, "" } @@ -2079,7 +2108,10 @@ func isProtocolOrInputError(errMsg string) bool { strings.Contains(lower, "forbidden") || strings.Contains(lower, "quota exceeded") || strings.Contains(lower, "payment required") || - strings.Contains(lower, "rate limit") { + strings.Contains(lower, "rate limit") || + strings.Contains(lower, "provider is busy") || + strings.Contains(lower, "rate-limited") || + strings.Contains(lower, "unavailable") { return false } return true @@ -2594,8 +2626,10 @@ type GradioConfigResponse struct { } type HFSpaceCardData struct { - Title string `json:"title"` - ShortDescription string `json:"short_description"` + Title string `json:"title"` + ShortDescription string `json:"short_description"` + Models []string `json:"models"` + SdkVersion string `json:"sdk_version"` } type HFSpaceInfoResponse struct { @@ -2627,6 +2661,8 @@ type SpaceDiscovery struct { FnIndex int `json:"fn_index"` // -1 if not applicable Models []string `json:"models"` PrimaryModel string `json:"primary_model"` + ModelIndex int `json:"model_index"` // -1 if none + IsOmniForge bool `json:"is_omniforge"` TotalInputs int `json:"total_inputs"` RawTotalInputs int `json:"raw_total_inputs,omitempty"` RawDefaultInputs []interface{} `json:"raw_default_inputs,omitempty"` @@ -2645,6 +2681,7 @@ type SpaceDiscovery struct { FunctionsJSONIndex int `json:"functions_json_index"` // -1 if none PreservedThinkingIndex int `json:"preserved_thinking_index"` // -1 if none WebSearchIndex int `json:"web_search_index"` // -1 if none + ImageIndex int `json:"image_index"` // -1 if none IsHunyuan3 bool `json:"is_hunyuan3"` HistoryFormat string `json:"history_format"` // "messages", "pairs", "gradio_messages", "none" ToolCallMode string `json:"tool_call_mode"` // "native_slot", "prompt_augmented_system", "prompt_augmented_first_turn", "prompt_augmented_single_prompt" @@ -2672,6 +2709,9 @@ func (d *SpaceDiscovery) Summary() string { if len(d.Models) > 0 { sb.WriteString(fmt.Sprintf("Exposed Models: %s\n", strings.Join(d.Models, ", "))) } + if d.ModelIndex >= 0 { + sb.WriteString(fmt.Sprintf("Model Slot Index: %d\n", d.ModelIndex)) + } sb.WriteString(fmt.Sprintf("History Format: %s\n", d.HistoryFormat)) sb.WriteString(fmt.Sprintf("Tool Call Support: %s\n", d.ToolCallMode)) sb.WriteString(fmt.Sprintf("Total Input Slots: %d\n", d.TotalInputs)) @@ -2765,6 +2805,15 @@ func (d *SpaceDiscovery) MatchesModel(requested string) bool { return true } + if d.ModelIndex >= 0 && d.ModelIndex < len(d.ParamMappings) { + for _, ch := range d.ParamMappings[d.ModelIndex].Choices { + chClean := strings.ToLower(ch) + if chClean == cleanReq || strings.HasSuffix(chClean, "/"+cleanReq) || strings.Contains(chClean, cleanReq) { + return true + } + } + } + return cleanReq == "default" || cleanReq == "gradio" || cleanReq == "gradio-chat" } @@ -2782,6 +2831,8 @@ func NewDefaultSpaceDiscovery(spaceURL string) *SpaceDiscovery { Endpoint: "/chat_fn", CleanEndpoint: "chat_fn", FnIndex: -1, + ModelIndex: -1, + IsOmniForge: false, TotalInputs: 1, HistoryIndex: -1, MessageIndex: 0, @@ -2795,6 +2846,7 @@ func NewDefaultSpaceDiscovery(spaceURL string) *SpaceDiscovery { FunctionsJSONIndex: -1, PreservedThinkingIndex: -1, WebSearchIndex: -1, + ImageIndex: -1, HistoryFormat: "messages", ToolCallMode: "prompt_augmented_single_prompt", LastDiscovered: time.Now(), @@ -3043,10 +3095,14 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } discovery := NewDefaultSpaceDiscovery(cleanURL) + if strings.Contains(strings.ToLower(cleanURL), "omniforge") { + discovery.IsOmniForge = true + } // 1. Try fetching /gradio_api/info or /info var infoResp GradioAPIInfoResponse infoFetched := false + var linkHeader string for _, path := range []string{"/gradio_api/info", "/info"} { infoURL := cleanURL + path @@ -3054,13 +3110,18 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover if err == nil { req.Header.Set("User-Agent", userAgent) resp, err := client.Do(req) - if err == nil && resp.StatusCode == http.StatusOK { - if json.NewDecoder(resp.Body).Decode(&infoResp) == nil { - infoFetched = true - if path == "/gradio_api/info" { - discovery.APIPrefix = "/gradio_api" - } else { - discovery.APIPrefix = "" + if err == nil { + if l := resp.Header.Get("Link"); l != "" && linkHeader == "" { + linkHeader = l + } + if resp.StatusCode == http.StatusOK { + if json.NewDecoder(resp.Body).Decode(&infoResp) == nil { + infoFetched = true + if path == "/gradio_api/info" { + discovery.APIPrefix = "/gradio_api" + } else { + discovery.APIPrefix = "" + } } } resp.Body.Close() @@ -3083,14 +3144,22 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover if err == nil { req.Header.Set("User-Agent", userAgent) resp, err := client.Do(req) - if err == nil && resp.StatusCode == http.StatusOK { - if json.NewDecoder(resp.Body).Decode(&configResp) == nil { - configFetched = true - if configResp.APIPrefix != "" { - discovery.APIPrefix = configResp.APIPrefix - } - if configResp.Title != "" { - discovery.Title = configResp.Title + if err == nil { + if l := resp.Header.Get("Link"); l != "" && linkHeader == "" { + linkHeader = l + } + if resp.StatusCode == http.StatusOK { + if json.NewDecoder(resp.Body).Decode(&configResp) == nil { + configFetched = true + if configResp.APIPrefix != "" { + discovery.APIPrefix = configResp.APIPrefix + } + if configResp.Title != "" { + discovery.Title = configResp.Title + if strings.Contains(strings.ToLower(configResp.Title), "omniforge") { + discovery.IsOmniForge = true + } + } } } resp.Body.Close() @@ -3106,12 +3175,35 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover // 3. Inspect Hugging Face Space Metadata if hosted on HF parsedURL, _ := url.Parse(cleanURL) if parsedURL != nil && (strings.HasSuffix(parsedURL.Host, ".hf.space") || strings.Contains(parsedURL.Host, "huggingface.co")) { - subdomain := strings.TrimSuffix(parsedURL.Host, ".hf.space") + if linkHeader == "" { + req, err := http.NewRequest("GET", cleanURL, nil) + if err == nil { + req.Header.Set("User-Agent", userAgent) + resp, _ := client.Do(req) + if resp != nil { + if l := resp.Header.Get("Link"); l != "" { + linkHeader = l + } + resp.Body.Close() + } + } + } + var owner, name string - dashIdx := strings.Index(subdomain, "-") - if dashIdx != -1 { - owner = subdomain[:dashIdx] - name = subdomain[dashIdx+1:] + hfLinkRegex := regexp.MustCompile(`(?i)https:\/\/huggingface\.co\/spaces\/([^\/>\s]+)\/([^\/>\s;]+)`) + if linkHeader != "" { + if m := hfLinkRegex.FindStringSubmatch(linkHeader); len(m) >= 3 { + owner = m[1] + name = m[2] + } + } + if owner == "" || name == "" { + subdomain := strings.TrimSuffix(parsedURL.Host, ".hf.space") + dashIdx := strings.Index(subdomain, "-") + if dashIdx != -1 { + owner = subdomain[:dashIdx] + name = subdomain[dashIdx+1:] + } } if owner != "" && name != "" { @@ -3130,10 +3222,32 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover if cleanM != m { discovery.Models = append(discovery.Models, cleanM) } + if slashIdx := strings.LastIndex(cleanM, "/"); slashIdx != -1 { + shortM := cleanM[slashIdx+1:] + discovery.Models = append(discovery.Models, shortM, strings.ToLower(shortM)) + } + } + for _, m := range hfResp.CardData.Models { + discovery.Models = append(discovery.Models, m) + cleanM := strings.TrimPrefix(m, "openai/") + cleanM = strings.TrimPrefix(cleanM, "models/") + if cleanM != m { + discovery.Models = append(discovery.Models, cleanM) + } + if slashIdx := strings.LastIndex(cleanM, "/"); slashIdx != -1 { + shortM := cleanM[slashIdx+1:] + discovery.Models = append(discovery.Models, shortM, strings.ToLower(shortM)) + } + } + if name != "" { + discovery.Models = append(discovery.Models, name, strings.ToLower(name)) } if hfResp.CardData.Title != "" && discovery.Title == "" { discovery.Title = hfResp.CardData.Title } + if hfResp.CardData.SdkVersion != "" && (discovery.GradioVersion == "" || discovery.GradioVersion == "unknown") { + discovery.GradioVersion = hfResp.CardData.SdkVersion + } if len(discovery.Models) > 0 { discovery.PrimaryModel = discovery.Models[0] } @@ -3289,6 +3403,7 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover discovery.ParamMappings = nil discovery.MessageIndex = -1 + paramIdx := 0 for idx, compID := range bestMatchingDep.Inputs { mapping := SpaceParamMapping{ InputIndex: idx, @@ -3296,39 +3411,21 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover ParamType: "other", } - var pName, pLabel, pComp string - if bestEndpointInfo != nil && idx < len(bestEndpointInfo.Parameters) { - p := bestEndpointInfo.Parameters[idx] - pName = strings.ToLower(p.ParameterName) - pLabel = strings.ToLower(p.Label) - pComp = strings.ToLower(p.Component) - if p.ParameterDefault != nil && discovery.DefaultInputs[idx] == nil { - discovery.DefaultInputs[idx] = p.ParameterDefault - mapping.DefaultValue = p.ParameterDefault - } - mapping.Label = p.ParameterName - mapping.ParamName = p.ParameterName - if p.Component != "" { - mapping.ComponentType = p.Component - } - } - + var cType, cLabel string if comp, exists := compMap[compID]; exists { - if mapping.ComponentType == "" { - mapping.ComponentType = comp.Type - } - cType := strings.ToLower(comp.Type) - cLabel := "" + cType = strings.ToLower(comp.Type) + mapping.ComponentType = comp.Type if comp.Props != nil { if l, ok := comp.Props["label"].(string); ok { cLabel = strings.ToLower(l) - if mapping.Label == "" { - mapping.Label = l - } + mapping.Label = l } - if val, ok := comp.Props["value"]; ok { + if val, ok := comp.Props["value"]; ok && val != nil { discovery.DefaultInputs[idx] = val mapping.DefaultValue = val + } else if defVal, ok := comp.Props["default_value"]; ok { + discovery.DefaultInputs[idx] = defVal + mapping.DefaultValue = defVal } if chList, ok := comp.Props["choices"].([]interface{}); ok { for _, ch := range chList { @@ -3342,8 +3439,48 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } } } - if bestEndpointInfo != nil && idx < len(bestEndpointInfo.Parameters) && len(mapping.Choices) == 0 { - p := bestEndpointInfo.Parameters[idx] + } + + isInternalState := false + if comp, exists := compMap[compID]; exists { + if comp.SkipAPI { + isInternalState = true + } else if cType == "state" { + if bestEndpointInfo != nil && paramIdx < len(bestEndpointInfo.Parameters) && strings.ToLower(bestEndpointInfo.Parameters[paramIdx].Component) != "state" { + isInternalState = true + } else if bestEndpointInfo != nil && paramIdx >= len(bestEndpointInfo.Parameters) { + isInternalState = true + } + } + } else if cType == "state" { + isInternalState = true + } + + var pName, pLabel, pComp string + var p *GradioParamInfo + if !isInternalState && bestEndpointInfo != nil && paramIdx < len(bestEndpointInfo.Parameters) { + p = &bestEndpointInfo.Parameters[paramIdx] + paramIdx++ + pName = strings.ToLower(p.ParameterName) + pLabel = strings.ToLower(p.Label) + pComp = strings.ToLower(p.Component) + if p.ParameterDefault != nil && (discovery.DefaultInputs[idx] == nil || discovery.DefaultInputs[idx] == "") { + discovery.DefaultInputs[idx] = p.ParameterDefault + mapping.DefaultValue = p.ParameterDefault + } + if mapping.Label == "" { + mapping.Label = p.ParameterName + } + mapping.ParamName = p.ParameterName + if mapping.ComponentType == "" && p.Component != "" { + mapping.ComponentType = p.Component + } + } + + if isInternalState { + mapping.ParamType = "state" + } else { + if p != nil && len(mapping.Choices) == 0 { if typeMap, ok := p.Type.(map[string]interface{}); ok { if enumArr, ok := typeMap["enum"].([]interface{}); ok { for _, e := range enumArr { @@ -3355,6 +3492,24 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } } + isObjectOrDict := false + if p != nil { + bType, _ := json.Marshal(p.Type) + bPyType, _ := json.Marshal(p.PythonType) + pTypeStr := strings.ToLower(string(bType)) + pPyTypeStr := strings.ToLower(string(bPyType)) + if strings.Contains(pTypeStr, "object") || strings.Contains(pPyTypeStr, "dict") { + isObjectOrDict = true + } + } + if comp, exists := compMap[compID]; exists && comp.Props != nil { + if apiInfo, ok := comp.Props["_api_info"].(map[string]interface{}); ok { + if t, ok := apiInfo["type"].(string); ok && t == "object" { + isObjectOrDict = true + } + } + } + if strings.Contains(pName, "function") || strings.Contains(pName, "tool") || strings.Contains(pLabel, "tool") || strings.Contains(pLabel, "function") || strings.Contains(cLabel, "tool") || strings.Contains(cLabel, "function") { mapping.ParamType = "tools" discovery.FunctionsJSONIndex = idx @@ -3367,7 +3522,7 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } else if strings.Contains(pName, "system") || strings.Contains(pLabel, "system") || strings.Contains(cLabel, "system") || strings.Contains(cLabel, "instruction") { mapping.ParamType = "system_prompt" discovery.SystemIndex = idx - if comp.Props != nil { + if comp, exists := compMap[compID]; exists && comp.Props != nil { if val, ok := comp.Props["value"].(string); ok && strings.TrimSpace(val) != "" { discovery.DefaultSystemPrompt = strings.TrimSpace(val) } @@ -3375,14 +3530,37 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } else if strings.Contains(pName, "history") || strings.Contains(pLabel, "history") || strings.Contains(cLabel, "history") || strings.Contains(cLabel, "chat") || cType == "chatbot" { mapping.ParamType = "history" discovery.HistoryIndex = idx - } else if cType == "multimodaltextbox" || strings.Contains(pComp, "multimodal") { + } else if cType == "image" || strings.Contains(pComp, "image") || strings.Contains(pName, "image") || strings.Contains(pLabel, "image") || strings.Contains(cLabel, "image") { + mapping.ParamType = "image" + discovery.ImageIndex = idx + } else if cType == "multimodaltextbox" || strings.Contains(pComp, "multimodal") || ((strings.Contains(pName, "message") || strings.Contains(pLabel, "message") || strings.Contains(cLabel, "message")) && isObjectOrDict) { mapping.ParamType = "message" discovery.MessageIndex = idx discovery.MessageIsMultimodal = true } else if strings.Contains(pName, "message") || strings.Contains(pLabel, "message") || strings.Contains(cLabel, "message") || strings.Contains(cLabel, "prompt") || strings.Contains(cLabel, "query") || (discovery.MessageIndex == -1 && idx == 0 && (cType == "textbox" || pComp == "textbox")) { mapping.ParamType = "message" discovery.MessageIndex = idx - discovery.MessageIsMultimodal = false + discovery.MessageIsMultimodal = isObjectOrDict + } else if (strings.Contains(pName, "model") || strings.Contains(pLabel, "model") || strings.Contains(cLabel, "model")) && (cType == "dropdown" || cType == "radio" || len(mapping.Choices) > 0 || cType == "textbox") { + mapping.ParamType = "model" + discovery.ModelIndex = idx + if len(mapping.Choices) > 0 { + for _, ch := range mapping.Choices { + if ch != "" { + discovery.Models = append(discovery.Models, ch) + if slashIdx := strings.LastIndex(ch, "/"); slashIdx != -1 { + discovery.Models = append(discovery.Models, ch[slashIdx+1:], strings.ToLower(ch[slashIdx+1:])) + } + } + } + if discovery.PrimaryModel == "" || discovery.PrimaryModel == "gradio-chat" { + if defStr, ok := mapping.DefaultValue.(string); ok && defStr != "" { + discovery.PrimaryModel = defStr + } else { + discovery.PrimaryModel = mapping.Choices[0] + } + } + } } else if strings.Contains(pName, "temp") || strings.Contains(cLabel, "temp") { mapping.ParamType = "temperature" discovery.TempIndex = idx @@ -3398,8 +3576,10 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } else if strings.Contains(pName, "search") || strings.Contains(pLabel, "search") || strings.Contains(cLabel, "search") || strings.Contains(pName, "browse") || strings.Contains(pLabel, "browse") || strings.Contains(cLabel, "browse") || strings.Contains(pName, "web") || strings.Contains(pLabel, "web") || strings.Contains(cLabel, "web") { mapping.ParamType = "web_search" discovery.WebSearchIndex = idx - } else if cType == "state" { - mapping.ParamType = "state" + } + + if strings.Contains(cLabel, "omniforge") || strings.Contains(pLabel, "omniforge") { + discovery.IsOmniForge = true } } discovery.ParamMappings = append(discovery.ParamMappings, mapping) @@ -3413,6 +3593,13 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover if strings.ToLower(comp.Type) == "multimodaltextbox" { discovery.MessageIsMultimodal = true } + if comp.Props != nil { + if apiInfo, ok := comp.Props["_api_info"].(map[string]interface{}); ok { + if t, ok := apiInfo["type"].(string); ok && t == "object" { + discovery.MessageIsMultimodal = true + } + } + } } } } @@ -3424,18 +3611,21 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover // and trailing inputs in bestMatchingDep.Inputs are unexposed server-side state components, // do not pad them with null so Gradio preserves its internal server state. if bestEndpointInfo != nil && len(bestEndpointInfo.Parameters) > 0 && len(bestMatchingDep.Inputs) > len(bestEndpointInfo.Parameters) { - allTrailingAreState := true + allTrailingAreUnexposedState := true for i := len(bestEndpointInfo.Parameters); i < len(bestMatchingDep.Inputs); i++ { cID := bestMatchingDep.Inputs[i] if comp, exists := compMap[cID]; exists { cType := strings.ToLower(comp.Type) - if cType != "state" && cType != "browserstate" { - allTrailingAreState = false + if !comp.SkipAPI && cType != "state" { + allTrailingAreUnexposedState = false break } + } else { + allTrailingAreUnexposedState = false + break } } - if allTrailingAreState { + if allTrailingAreUnexposedState { discovery.TotalInputs = len(bestEndpointInfo.Parameters) discovery.DefaultInputs = discovery.DefaultInputs[:discovery.TotalInputs] if len(discovery.ParamMappings) > discovery.TotalInputs { @@ -3468,6 +3658,9 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover if discovery.WebSearchIndex >= discovery.TotalInputs { discovery.WebSearchIndex = -1 } + if discovery.ModelIndex >= discovery.TotalInputs { + discovery.ModelIndex = -1 + } } } } @@ -3517,7 +3710,16 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover DefaultValue: p.ParameterDefault, } - if strings.Contains(pComp, "multimodal") { + isObjectOrDict := false + bType, _ := json.Marshal(p.Type) + bPyType, _ := json.Marshal(p.PythonType) + pTypeStr := strings.ToLower(string(bType)) + pPyTypeStr := strings.ToLower(string(bPyType)) + if strings.Contains(pTypeStr, "object") || strings.Contains(pPyTypeStr, "dict") { + isObjectOrDict = true + } + + if strings.Contains(pComp, "multimodal") || ((strings.Contains(pName, "message") || strings.Contains(pLabel, "message")) && isObjectOrDict) { if discovery.MessageIndex == -1 || strings.Contains(pLabel, "message") || strings.Contains(pName, "message") { mapping.ParamType = "message" discovery.MessageIndex = idx @@ -3534,19 +3736,18 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } else if strings.Contains(pName, "history") || strings.Contains(pLabel, "history") || strings.Contains(pName, "chat") || strings.Contains(pName, "messages") || strings.Contains(pName, "conversation") || pComp == "chatbot" { discovery.HistoryIndex = idx mapping.ParamType = "history" - bType, _ := json.Marshal(p.Type) - bPyType, _ := json.Marshal(p.PythonType) - pPyType := strings.ToLower(string(bPyType)) - bTypeStr := strings.ToLower(string(bType)) - if strings.Contains(pPyType, "list[tuple[") || strings.Contains(pPyType, "list[list[") || strings.Contains(bTypeStr, "tuple") { + if strings.Contains(pPyTypeStr, "list[tuple[") || strings.Contains(pPyTypeStr, "list[list[") || strings.Contains(pTypeStr, "tuple") { discovery.HistoryFormat = "pairs" - } else if strings.Contains(pPyType, "textmessage") || strings.Contains(pPyType, "dict(text: str") || strings.Contains(bTypeStr, "textmessage") || strings.Contains(bTypeStr, "chatbotdatamessages") { + } else if strings.Contains(pPyTypeStr, "textmessage") || strings.Contains(pPyTypeStr, "dict(text: str") || strings.Contains(pTypeStr, "textmessage") || strings.Contains(pTypeStr, "chatbotdatamessages") { discovery.HistoryFormat = "gradio_messages" } + } else if pComp == "image" || strings.Contains(pName, "image") || strings.Contains(pLabel, "image") { + discovery.ImageIndex = idx + mapping.ParamType = "image" } else if strings.Contains(pLabel, "message") || strings.Contains(pName, "message") || (strings.Contains(pLabel, "prompt") && !strings.Contains(pLabel, "system")) || (strings.Contains(pName, "prompt") && !strings.Contains(pName, "system")) || strings.Contains(pLabel, "query") || strings.Contains(pName, "query") || strings.Contains(pLabel, "question") || strings.Contains(pName, "question") || (discovery.MessageIndex == -1 && (pComp == "textbox" || (idx == 0 && pComp != "chatbot"))) { discovery.MessageIndex = idx mapping.ParamType = "message" - discovery.MessageIsMultimodal = false + discovery.MessageIsMultimodal = isObjectOrDict } else if strings.Contains(pName, "think_level") || strings.Contains(pName, "thinking_level") || strings.Contains(pLabel, "think") { discovery.ThinkLevelIndex = idx mapping.ParamType = "think_level" @@ -3595,7 +3796,9 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover // 7. Resolve default history format if not set by type inspection if discovery.HistoryIndex != -1 { if discovery.HistoryFormat == "" || discovery.HistoryFormat == "messages" { - if strings.HasPrefix(discovery.GradioVersion, "5.") || strings.HasPrefix(discovery.GradioVersion, "6.") { + if discovery.Flavor == "server" { + discovery.HistoryFormat = "messages" + } else if strings.HasPrefix(discovery.GradioVersion, "5.") || strings.HasPrefix(discovery.GradioVersion, "6.") { discovery.HistoryFormat = "gradio_messages" } else { discovery.HistoryFormat = "pairs" @@ -3606,7 +3809,8 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover } // 8. Hunyuan3 / Tencent Hy3 overrides - if discovery.FunctionsJSONIndex != -1 || discovery.ThinkLevelIndex != -1 || strings.Contains(cleanURL, "hy3") || strings.Contains(cleanURL, "hunyuan") { + isHy3Space := strings.Contains(cleanURL, "hy3") || strings.Contains(cleanURL, "hunyuan") || (discovery.FunctionsJSONIndex != -1 && discovery.PreservedThinkingIndex != -1) + if isHy3Space { discovery.IsHunyuan3 = true discovery.HistoryFormat = "messages" discovery.Models = append(discovery.Models, "hy3", "hunyuan3", "tencent/Hy3") @@ -3623,6 +3827,8 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover discovery.ToolCallMode = "native_slot" } else if discovery.SystemIndex >= 0 { discovery.ToolCallMode = "prompt_augmented_system" + } else if discovery.IsOmniForge { + discovery.ToolCallMode = "prompt_augmented_first_turn" } else if discovery.HistoryIndex >= 0 { discovery.ToolCallMode = "prompt_augmented_first_turn" } else { @@ -3873,7 +4079,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet var promptMessageText string isToolReturn := strings.HasPrefix(lastUserMessage, "") || strings.HasPrefix(lastUserMessage, "Tool result") - if disc.HistoryIndex != -1 { + if disc.HistoryIndex != -1 && !disc.IsOmniForge { if disc.SystemIndex == -1 { if len(nonSystem) > 1 && toolInstruction != "" { if !isToolReturn { @@ -3892,7 +4098,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } } } else { - // Single message space: compose multi-turn history into the prompt + // Single message space or OmniForge: compose multi-turn history into the prompt if len(nonSystem) <= 1 { if systemPromptStr != "" && len(nonSystem) == 1 { if toolInstruction != "" && !isToolReturn { @@ -3946,11 +4152,11 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } } - // Extract multimodal files if message input is multimodal + // Extract multimodal files if message input is multimodal or image slot is present var messageFiles []interface{} - if disc.MessageIsMultimodal && len(nonSystem) > 0 { - lastMsg := nonSystem[len(nonSystem)-1] - if parts, ok := lastMsg.Content.([]interface{}); ok { + var primaryImage interface{} + for _, m := range req.Messages { + if parts, ok := m.Content.([]interface{}); ok { for _, p := range parts { if itemMap, ok := p.(map[string]interface{}); ok { if itemMap["type"] == "image_url" { @@ -3963,11 +4169,15 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet imgURL = iuStr } if imgURL != "" { - messageFiles = append(messageFiles, map[string]interface{}{ + fileData := map[string]interface{}{ "path": imgURL, "url": imgURL, "meta": map[string]interface{}{"_type": "gradio.FileData"}, - }) + } + messageFiles = append(messageFiles, fileData) + if primaryImage == nil { + primaryImage = fileData + } } } } @@ -3991,8 +4201,14 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } } + if disc.ImageIndex >= 0 && disc.ImageIndex != disc.MessageIndex && disc.ImageIndex != disc.HistoryIndex && disc.ImageIndex < len(data) { + data[disc.ImageIndex] = primaryImage + } + if disc.HistoryIndex >= 0 && disc.HistoryIndex < len(data) { - if disc.HistoryFormat == "pairs" { + if disc.IsOmniForge { + data[disc.HistoryIndex] = []map[string]interface{}{} + } else if disc.HistoryFormat == "pairs" { var pairs [][]string for i := 0; i < len(historyArray); i += 2 { u := "" @@ -4044,6 +4260,9 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet "content": promptMessageText, }) } + if historyArray == nil { + historyArray = []map[string]interface{}{} + } data[disc.HistoryIndex] = historyArray } } @@ -4058,21 +4277,47 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } if disc.ThinkLevelIndex >= 0 && disc.ThinkLevelIndex != disc.MessageIndex && disc.ThinkLevelIndex != disc.HistoryIndex && 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 + isCheckbox := false + if disc.ThinkLevelIndex < len(disc.ParamMappings) { + cType := strings.ToLower(disc.ParamMappings[disc.ThinkLevelIndex].ComponentType) + if cType == "checkbox" || cType == "bool" || cType == "boolean" { + isCheckbox = true } } - data[disc.ThinkLevelIndex] = thinkLevel + if !isCheckbox { + if _, isBool := data[disc.ThinkLevelIndex].(bool); isBool { + isCheckbox = true + } + } + + if isCheckbox { + thinkBool := true + if req.ReasoningEffort != "" { + effort := strings.ToLower(req.ReasoningEffort) + if effort == "none" || effort == "off" || effort == "no_think" || effort == "0" || effort == "false" { + thinkBool = false + } else { + thinkBool = true + } + } + data[disc.ThinkLevelIndex] = thinkBool + } else { + 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.PreservedThinkingIndex >= 0 && disc.PreservedThinkingIndex != disc.MessageIndex && disc.PreservedThinkingIndex != disc.HistoryIndex && disc.PreservedThinkingIndex != disc.SystemIndex && disc.PreservedThinkingIndex < len(data) { @@ -4122,35 +4367,142 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet data[disc.FunctionsJSONIndex] = functionsJSONStr } - if disc.WebSearchIndex >= 0 && disc.WebSearchIndex != disc.MessageIndex && disc.WebSearchIndex != disc.HistoryIndex && disc.WebSearchIndex != disc.SystemIndex && disc.WebSearchIndex < len(data) && len(req.Tools) > 0 { + if disc.WebSearchIndex >= 0 && disc.WebSearchIndex != disc.MessageIndex && disc.WebSearchIndex != disc.HistoryIndex && disc.WebSearchIndex != disc.SystemIndex && disc.WebSearchIndex < len(data) { + if req.WebSearch != nil { + if *req.WebSearch { + var mapping *SpaceParamMapping + for i := range disc.ParamMappings { + if disc.ParamMappings[i].InputIndex == disc.WebSearchIndex { + mapping = &disc.ParamMappings[i] + break + } + } + if mapping != nil && len(mapping.Choices) > 0 { + for _, choice := range mapping.Choices { + cLower := strings.ToLower(choice) + if cLower == "enable" || cLower == "on" || cLower == "enabled" || strings.Contains(cLower, "search") { + data[disc.WebSearchIndex] = choice + break + } + } + } else { + data[disc.WebSearchIndex] = true + } + } else { + var mapping *SpaceParamMapping + for i := range disc.ParamMappings { + if disc.ParamMappings[i].InputIndex == disc.WebSearchIndex { + mapping = &disc.ParamMappings[i] + break + } + } + disabledSet := false + if mapping != nil && len(mapping.Choices) > 0 { + for _, choice := range mapping.Choices { + cLower := strings.ToLower(choice) + if cLower == "direct" || cLower == "off" || cLower == "disabled" || cLower == "none" || cLower == "false" || cLower == "no" || strings.Contains(cLower, "direct") || strings.Contains(cLower, "no search") || strings.Contains(cLower, "disable") { + data[disc.WebSearchIndex] = choice + disabledSet = true + break + } + } + } + if !disabledSet { + data[disc.WebSearchIndex] = false + } + } + } else if len(req.Tools) > 0 { + var mapping *SpaceParamMapping + for i := range disc.ParamMappings { + if disc.ParamMappings[i].InputIndex == disc.WebSearchIndex { + mapping = &disc.ParamMappings[i] + break + } + } + disabledSet := false + if mapping != nil && len(mapping.Choices) > 0 { + for _, choice := range mapping.Choices { + cLower := strings.ToLower(choice) + if cLower == "direct" || cLower == "off" || cLower == "disabled" || cLower == "none" || cLower == "false" || cLower == "no" || strings.Contains(cLower, "direct") || strings.Contains(cLower, "no search") || strings.Contains(cLower, "disable") { + data[disc.WebSearchIndex] = choice + disabledSet = true + break + } + } + } + if !disabledSet { + switch data[disc.WebSearchIndex].(type) { + case bool: + data[disc.WebSearchIndex] = false + case string: + strVal := strings.ToLower(data[disc.WebSearchIndex].(string)) + if strings.Contains(strVal, "search") { + data[disc.WebSearchIndex] = "Direct" + } + } + } + } + } + + if disc.ModelIndex >= 0 && disc.ModelIndex != disc.MessageIndex && disc.ModelIndex < len(data) { var mapping *SpaceParamMapping for i := range disc.ParamMappings { - if disc.ParamMappings[i].InputIndex == disc.WebSearchIndex { + if disc.ParamMappings[i].InputIndex == disc.ModelIndex { mapping = &disc.ParamMappings[i] break } } - disabledSet := false - if mapping != nil && len(mapping.Choices) > 0 { - for _, choice := range mapping.Choices { - cLower := strings.ToLower(choice) - if cLower == "direct" || cLower == "off" || cLower == "disabled" || cLower == "none" || cLower == "false" || cLower == "no" || strings.Contains(cLower, "direct") || strings.Contains(cLower, "no search") || strings.Contains(cLower, "disable") { - data[disc.WebSearchIndex] = choice - disabledSet = true + targetModel := req.Model + if targetModel == "" { + targetModel = ConfiguredModelName + } + if targetModel != "" && mapping != nil && len(mapping.Choices) > 0 { + matchedChoice := "" + for _, ch := range mapping.Choices { + if ch == targetModel { + matchedChoice = ch break } } - } - if !disabledSet { - switch data[disc.WebSearchIndex].(type) { - case bool: - data[disc.WebSearchIndex] = false - case string: - strVal := strings.ToLower(data[disc.WebSearchIndex].(string)) - if strings.Contains(strVal, "search") { - data[disc.WebSearchIndex] = "Direct" + if matchedChoice == "" { + for _, ch := range mapping.Choices { + if strings.EqualFold(ch, targetModel) { + matchedChoice = ch + break + } } } + if matchedChoice == "" { + for _, ch := range mapping.Choices { + if strings.HasSuffix(strings.ToLower(ch), "/"+strings.ToLower(targetModel)) || strings.EqualFold(ch, targetModel) { + matchedChoice = ch + break + } + } + } + if matchedChoice == "" { + for _, ch := range mapping.Choices { + if strings.Contains(strings.ToLower(ch), strings.ToLower(targetModel)) { + matchedChoice = ch + break + } + } + } + if matchedChoice != "" { + data[disc.ModelIndex] = matchedChoice + } + } else if targetModel != "" && (mapping == nil || len(mapping.Choices) == 0) { + data[disc.ModelIndex] = targetModel + } + } + + for idx := range data { + if idx < len(disc.ParamMappings) { + pType := strings.ToLower(disc.ParamMappings[idx].ComponentType) + pName := strings.ToLower(disc.ParamMappings[idx].ParamName) + if (pType == "browserstate" || strings.Contains(pName, "memory")) && data[idx] == nil { + data[idx] = []interface{}{} + } } } @@ -4302,6 +4654,13 @@ func extractGradioDiffDelta(v []interface{}) (contentDelta string, reasoningDelt func ParseGradioStreamOutput(rawJSON string) (frame GradioOutputFrame) { isNativeTuple := false defer func() { + if !isNativeTuple && frame.OK && frame.Reasoning == "" && frame.Content != "" { + clean, reasoning := ExtractThinking(frame.Content) + if clean != frame.Content || reasoning != "" { + frame.Content = clean + frame.Reasoning = reasoning + } + } if !isNativeTuple && frame.OK && len(frame.ToolCalls) == 0 && frame.Content != "" { tcs, clean, has := DetectToolCalls(frame.Content) if has && len(tcs) > 0 { @@ -4337,22 +4696,31 @@ func ParseGradioStreamOutput(rawJSON string) (frame GradioOutputFrame) { // 1. Check if v[0] is an inner slice with len >= 2 (e.g. Hy3: [[content, reasoning, tool_calls, history]]) if inner, ok := v[0].([]interface{}); ok && len(inner) >= 2 { - isNativeTuple = true - s0, _ := inner[0].(string) - s1, _ := inner[1].(string) - frame.Content = s0 - frame.Reasoning = s1 - if len(inner) >= 3 && 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 - } + isChatbotItem := false + if inner[0] != nil { + switch inner[0].(type) { + case map[string]interface{}, []interface{}: + isChatbotItem = true } } - frame.OK = true - return frame + if !isChatbotItem { + isNativeTuple = true + s0, _ := inner[0].(string) + s1, _ := inner[1].(string) + frame.Content = s0 + frame.Reasoning = s1 + if len(inner) >= 3 && 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 + } } // 2. Check if any element of v is a Chatbot message list or Chatbot pair list @@ -4742,6 +5110,9 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re } if resp == nil { + if lastErr != nil && strings.Contains(strings.ToLower(lastErr.Error()), "space is paused") { + return fmt.Errorf("upstream space is paused: %w", lastErr) + } log.Printf("Gradio /queue/join unavailable (%v), falling back to /run/predict protocol...", lastErr) return g.executePredictCompletion(w, r, disc, gradioData, req, completionID, createdTime, modelName, effUA) } @@ -4880,7 +5251,7 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re var prevReasoning string lastToolCallArgs := make(map[string]string) emittedToolCallIDs := make(map[string]bool) - nativeReasoningSeen := disc.IsHunyuan3 || disc.ThinkLevelIndex >= 0 || disc.PreservedThinkingIndex >= 0 + nativeReasoningSeen := disc.IsHunyuan3 || disc.PreservedThinkingIndex >= 0 nativeToolCallsSeen := false var streamErr error @@ -4922,7 +5293,12 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re if isNativeToolCalling { // 1. Native reasoning if len(frame.Reasoning) > len(prevReasoning) { - deltaReasoning := frame.Reasoning[len(prevReasoning):] + var deltaReasoning string + if strings.HasPrefix(frame.Reasoning, prevReasoning) { + deltaReasoning = frame.Reasoning[len(prevReasoning):] + } else { + deltaReasoning = frame.Reasoning + } prevReasoning = frame.Reasoning streamer.Reasoning(deltaReasoning) } else if frame.IsDelta && frame.Reasoning != "" { @@ -4963,7 +5339,12 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re // 3. Native content if len(frame.Content) > len(prevContent) { - cDelta := frame.Content[len(prevContent):] + var cDelta string + if strings.HasPrefix(frame.Content, prevContent) { + cDelta = frame.Content[len(prevContent):] + } else { + cDelta = frame.Content + } prevContent = frame.Content streamer.Content(cDelta) } else if frame.IsDelta && frame.Content != "" { @@ -4978,8 +5359,10 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re deltaReasoning = frame.Reasoning prevReasoning += deltaReasoning } else { - if len(frame.Reasoning) > len(prevReasoning) { + if len(frame.Reasoning) > len(prevReasoning) && strings.HasPrefix(frame.Reasoning, prevReasoning) { deltaReasoning = frame.Reasoning[len(prevReasoning):] + } else if !strings.HasPrefix(frame.Reasoning, prevReasoning) { + deltaReasoning = frame.Reasoning } prevReasoning = frame.Reasoning } @@ -5026,8 +5409,10 @@ func (g *GradioGateway) executeQueueCompletion(w http.ResponseWriter, r *http.Re delta = frame.Content prevContent += delta } else { - if len(currentText) > len(prevContent) { + if len(currentText) > len(prevContent) && strings.HasPrefix(currentText, prevContent) { delta = currentText[len(prevContent):] + } else if !strings.HasPrefix(currentText, prevContent) { + delta = currentText } prevContent = currentText } @@ -5186,6 +5571,9 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req if pName == "" { pName = fmt.Sprintf("param_%d", idx) } + if val == nil && idx < len(disc.ParamMappings) && disc.ParamMappings[idx].DefaultValue != nil { + val = disc.ParamMappings[idx].DefaultValue + } v2Payload[pName] = val } return json.Marshal(v2Payload) @@ -5227,6 +5615,9 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req resp, err = DoWithFibonacciRetry(g.client, makeCallReq, 3) } if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "space is paused") { + return fmt.Errorf("upstream space is paused: %w", err) + } if fg, ok := extractFailedGeneration(err.Error()); ok { if tcs, _, has := DetectToolCalls(fg); has && len(tcs) > 0 { if !req.Stream { @@ -5261,6 +5652,9 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req } resp, err = DoWithFibonacciRetry(g.client, makeAltReq, 3) if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "space is paused") { + return fmt.Errorf("upstream space is paused: %w", err) + } if fg, ok := extractFailedGeneration(err.Error()); ok { if tcs, _, has := DetectToolCalls(fg); has && len(tcs) > 0 { if !req.Stream { @@ -5310,6 +5704,21 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req } streamResp, err := DoWithFibonacciRetry(g.client, makeStreamReq, 5) + if err != nil && isV2 { + altStreamURL := fmt.Sprintf("%s%s/%s/%s/%s", disc.SpaceURL, disc.APIPrefix, callPath, disc.CleanEndpoint, joinRes.EventID) + if altStreamURL != streamURL { + makeAltStreamReq := func() (*http.Request, error) { + r, err := http.NewRequest("GET", altStreamURL, nil) + if err != nil { + return nil, err + } + r.Header.Set("Accept", "text/event-stream") + r.Header.Set("User-Agent", effUA) + return r, nil + } + streamResp, err = DoWithFibonacciRetry(g.client, makeAltStreamReq, 3) + } + } if err != nil { if fg, ok := extractFailedGeneration(err.Error()); ok { if tcs, _, has := DetectToolCalls(fg); has && len(tcs) > 0 { @@ -5430,7 +5839,7 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req var prevReasoning string lastToolCallArgs := make(map[string]string) emittedToolCallIDs := make(map[string]bool) - nativeReasoningSeen := disc.IsHunyuan3 || disc.ThinkLevelIndex >= 0 || disc.PreservedThinkingIndex >= 0 + nativeReasoningSeen := disc.IsHunyuan3 || disc.PreservedThinkingIndex >= 0 nativeToolCallsSeen := false currentEvent := "" var streamErr error @@ -5481,7 +5890,12 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req if isNativeToolCalling { // 1. Native reasoning if len(frame.Reasoning) > len(prevReasoning) { - deltaReasoning := frame.Reasoning[len(prevReasoning):] + var deltaReasoning string + if strings.HasPrefix(frame.Reasoning, prevReasoning) { + deltaReasoning = frame.Reasoning[len(prevReasoning):] + } else { + deltaReasoning = frame.Reasoning + } prevReasoning = frame.Reasoning streamer.Reasoning(deltaReasoning) } else if frame.IsDelta && frame.Reasoning != "" { @@ -5522,7 +5936,12 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req // 3. Native content if len(frame.Content) > len(prevContent) { - cDelta := frame.Content[len(prevContent):] + var cDelta string + if strings.HasPrefix(frame.Content, prevContent) { + cDelta = frame.Content[len(prevContent):] + } else { + cDelta = frame.Content + } prevContent = frame.Content streamer.Content(cDelta) } else if frame.IsDelta && frame.Content != "" { @@ -5537,8 +5956,10 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req deltaReasoning = frame.Reasoning prevReasoning += deltaReasoning } else { - if len(frame.Reasoning) > len(prevReasoning) { + if len(frame.Reasoning) > len(prevReasoning) && strings.HasPrefix(frame.Reasoning, prevReasoning) { deltaReasoning = frame.Reasoning[len(prevReasoning):] + } else if !strings.HasPrefix(frame.Reasoning, prevReasoning) { + deltaReasoning = frame.Reasoning } prevReasoning = frame.Reasoning } @@ -5585,8 +6006,10 @@ func (g *GradioGateway) ExecuteChatCompletion(w http.ResponseWriter, r *http.Req delta = currentText prevContent += delta } else { - if len(currentText) > len(prevContent) { + if len(currentText) > len(prevContent) && strings.HasPrefix(currentText, prevContent) { delta = currentText[len(prevContent):] + } else if !strings.HasPrefix(currentText, prevContent) { + delta = currentText } prevContent = currentText } diff --git a/gr2gw_test.go b/gr2gw_test.go index 07bd274..c75725e 100644 --- a/gr2gw_test.go +++ b/gr2gw_test.go @@ -4058,4 +4058,680 @@ func TestQueueToolCallingStreamingParity(t *testing.T) { } } +func TestServerFlavorMultimodalDictMessageAndHistory(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/config" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "version": "6.20.0", + "api_prefix": "/gradio_api", + "mode": "server", + "components": []map[string]interface{}{ + { + "id": 1, + "type": "api", + "props": map[string]interface{}{ + "label": "1st", + "_api_info": map[string]interface{}{"type": "object"}, + }, + }, + { + "id": 2, + "type": "api", + "props": map[string]interface{}{ + "label": "2nd", + "_api_info": map[string]interface{}{"type": "array"}, + }, + }, + }, + "dependencies": []map[string]interface{}{ + { + "id": 0, + "api_name": "chat", + "inputs": []int{1, 2}, + "outputs": []int{1}, + "types": map[string]interface{}{"generator": true}, + }, + }, + }) + return + } + if r.URL.Path == "/info" || 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", + "type": map[string]interface{}{"type": "object"}, + "python_type": map[string]interface{}{"type": "dict(str, Any)"}, + }, + { + "parameter_name": "history", + "component": "Api", + "type": map[string]interface{}{"type": "array"}, + "python_type": map[string]interface{}{"type": "list[Any]"}, + }, + }, + }, + }, + }) + return + } + + http.NotFound(w, r) + })) + defer ts.Close() + + disc, err := InspectSpace(http.DefaultClient, ts.URL, "test-agent") + if err != nil { + t.Fatalf("InspectSpace failed: %v", err) + } + + if disc.Flavor != "server" { + t.Errorf("expected Flavor 'server', got %q", disc.Flavor) + } + if !disc.MessageIsMultimodal { + t.Errorf("expected MessageIsMultimodal=true for dict message, got false") + } + if disc.HistoryFormat != "messages" { + t.Errorf("expected HistoryFormat='messages' for server flavor, got %q", disc.HistoryFormat) + } + + gw := &GradioGateway{} + req := ChatCompletionRequest{ + Model: "test-model", + Messages: []ChatMessage{ + {Role: "user", Content: "Hello world"}, + }, + } + + payload, err := gw.BuildGradioPayload(disc, req) + if err != nil { + t.Fatalf("BuildGradioPayload failed: %v", err) + } + + if len(payload) != 2 { + t.Fatalf("expected 2 payload slots, got %d", len(payload)) + } + + // Slot 0: Multimodal dictionary with "text" and "files" + msgDict, ok := payload[0].(map[string]interface{}) + if !ok { + t.Fatalf("expected slot 0 to be map[string]interface{}, got %T", payload[0]) + } + if msgDict["text"] != "Hello world" { + t.Errorf("expected text='Hello world', got %v", msgDict["text"]) + } + if files, ok := msgDict["files"].([]interface{}); !ok || len(files) != 0 { + t.Errorf("expected files to be empty slice, got %v", msgDict["files"]) + } + + // Slot 1: History must be empty slice, never nil + histArr, ok := payload[1].([]map[string]interface{}) + if !ok { + t.Fatalf("expected slot 1 to be []map[string]interface{}, got %T", payload[1]) + } + if histArr == nil { + t.Errorf("expected history to be non-nil slice, got nil") + } + if len(histArr) != 0 { + t.Errorf("expected 0 history items for single turn, got %d", len(histArr)) + } +} + +func TestGradio6ChatInterfaceInspectionAndPayload(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/config" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "version": "6.28.0", + "api_prefix": "/gradio_api", + "mode": "blocks", + "components": []map[string]interface{}{ + {"id": 12, "type": "textbox", "props": map[string]interface{}{"label": "message"}}, + {"id": 24, "type": "state", "props": map[string]interface{}{"label": "state"}}, + {"id": 4, "type": "image", "props": map[string]interface{}{"label": "Image (optional)"}}, + {"id": 5, "type": "checkbox", "props": map[string]interface{}{"label": "Web search", "value": true}}, + {"id": 6, "type": "checkbox", "props": map[string]interface{}{"label": "Thinking mode", "value": false}}, + {"id": 7, "type": "slider", "props": map[string]interface{}{"label": "Temperature", "value": 0.7}}, + {"id": 8, "type": "slider", "props": map[string]interface{}{"label": "Top-p", "value": 0.9}}, + {"id": 9, "type": "slider", "props": map[string]interface{}{"label": "Max tokens", "value": 8192}}, + }, + "dependencies": []map[string]interface{}{ + { + "id": 0, + "api_name": "chat", + "inputs": []int{12, 24, 4, 5, 6, 7, 8, 9}, + "outputs": []int{12}, + "types": map[string]interface{}{"generator": true}, + }, + }, + }) + return + } + + if r.URL.Path == "/info" || 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": "Textbox", "type": map[string]interface{}{"type": "string"}}, + {"parameter_name": "image", "component": "Image", "type": map[string]interface{}{"type": "string"}}, + {"parameter_name": "web_search", "component": "Checkbox", "type": map[string]interface{}{"type": "boolean"}, "default": true}, + {"parameter_name": "enable_thinking", "component": "Checkbox", "type": map[string]interface{}{"type": "boolean"}, "default": false}, + {"parameter_name": "temperature", "component": "Slider", "type": map[string]interface{}{"type": "number"}, "default": 0.7}, + {"parameter_name": "top_p", "component": "Slider", "type": map[string]interface{}{"type": "number"}, "default": 0.9}, + {"parameter_name": "max_tokens", "component": "Slider", "type": map[string]interface{}{"type": "integer"}, "default": 8192}, + }, + }, + }, + }) + return + } + + http.NotFound(w, r) + })) + defer ts.Close() + + disc, err := InspectSpace(http.DefaultClient, ts.URL, "test-agent") + if err != nil { + t.Fatalf("InspectSpace failed: %v", err) + } + + if disc.MessageIndex != 0 { + t.Errorf("expected MessageIndex=0, got %d", disc.MessageIndex) + } + if disc.ImageIndex != 2 { + t.Errorf("expected ImageIndex=2, got %d", disc.ImageIndex) + } + if disc.WebSearchIndex != 3 { + t.Errorf("expected WebSearchIndex=3, got %d", disc.WebSearchIndex) + } + if disc.ThinkLevelIndex != 4 { + t.Errorf("expected ThinkLevelIndex=4, got %d", disc.ThinkLevelIndex) + } + if disc.TempIndex != 5 { + t.Errorf("expected TempIndex=5, got %d", disc.TempIndex) + } + if disc.TopPIndex != 6 { + t.Errorf("expected TopPIndex=6, got %d", disc.TopPIndex) + } + if disc.MaxTokensIndex != 7 { + t.Errorf("expected MaxTokensIndex=7, got %d", disc.MaxTokensIndex) + } + if disc.IsHunyuan3 { + t.Errorf("expected IsHunyuan3=false for generic Gradio space, got true") + } + + gw := &GradioGateway{} + webSearchFalse := false + tempVal := 0.5 + topPVal := 0.8 + req := ChatCompletionRequest{ + Model: "qwen", + Messages: []ChatMessage{ + { + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "Describe this photo"}, + map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "https://example.com/cat.png"}}, + }, + }, + }, + ReasoningEffort: "high", + WebSearch: &webSearchFalse, + Temperature: &tempVal, + TopP: &topPVal, + MaxTokens: 2048, + } + + payload, err := gw.BuildGradioPayload(disc, req) + if err != nil { + t.Fatalf("BuildGradioPayload failed: %v", err) + } + + if len(payload) != 8 { + t.Fatalf("expected 8 payload items, got %d", len(payload)) + } + + // Slot 0: prompt text + if msg, ok := payload[0].(string); !ok || !strings.Contains(msg, "Describe this photo") { + t.Errorf("expected slot 0 to contain 'Describe this photo', got %v", payload[0]) + } + + // Slot 2: primary image FileData + imgMap, ok := payload[2].(map[string]interface{}) + if !ok { + t.Fatalf("expected slot 2 to be FileData map, got %T", payload[2]) + } + if imgMap["url"] != "https://example.com/cat.png" { + t.Errorf("expected image url 'https://example.com/cat.png', got %v", imgMap["url"]) + } + + // Slot 3: web search disabled + if ws, ok := payload[3].(bool); !ok || ws != false { + t.Errorf("expected slot 3 (web_search) to be false, got %v", payload[3]) + } + + // Slot 4: thinking mode checkbox enabled for "high" effort + if think, ok := payload[4].(bool); !ok || think != true { + t.Errorf("expected slot 4 (enable_thinking) to be true, got %v", payload[4]) + } + + // Slot 5: temperature + if temp, ok := payload[5].(float64); !ok || temp != 0.5 { + t.Errorf("expected slot 5 (temperature) to be 0.5, got %v", payload[5]) + } + + // Slot 6: top_p + if topP, ok := payload[6].(float64); !ok || topP != 0.8 { + t.Errorf("expected slot 6 (top_p) to be 0.8, got %v", payload[6]) + } + + // Slot 7: max_tokens + if maxTokens, ok := payload[7].(int); !ok || maxTokens != 2048 { + t.Errorf("expected slot 7 (max_tokens) to be 2048, got %v", payload[7]) + } +} + +func TestExtractThinkingAndDetailsHtmlFormat(t *testing.T) { + // 1. Closed details tag with thinking + raw1 := `
Thinking + +I need to analyze this prompt step by step. +
+ +Here is the answer.` + + rem1, think1 := ExtractThinking(raw1) + if think1 != "I need to analyze this prompt step by step." { + t.Errorf("unexpected reasoning: %q", think1) + } + if rem1 != "Here is the answer." { + t.Errorf("unexpected clean content: %q", rem1) + } + + // 2. Preceding web search results details tag preserved + raw2 := `
Web results + +1. https://example.com +Snippet +
+ +
Thinking + +Evaluating results... +
+ +Answer based on web results.` + + rem2, think2 := ExtractThinking(raw2) + if think2 != "Evaluating results..." { + t.Errorf("unexpected reasoning: %q", think2) + } + if !strings.Contains(rem2, "
Web results") { + t.Errorf("expected web results to remain in rem, got: %q", rem2) + } + if !strings.Contains(rem2, "Answer based on web results.") { + t.Errorf("expected final answer to remain in rem, got: %q", rem2) + } + if strings.Contains(rem2, "Thinking") { + t.Errorf("thinking tag should have been stripped from rem, got: %q", rem2) + } + + // 3. Open details tag during streaming + raw3 := `
Thinking + +Streaming thought step 1...` + rem3, think3 := ExtractThinking(raw3) + if think3 != "Streaming thought step 1..." { + t.Errorf("unexpected reasoning: %q", think3) + } + if rem3 != "" { + t.Errorf("expected empty content while thinking is open, got %q", rem3) + } + + // 4. ParseGradioStreamOutput separation test + streamJSON := `["
Thinking\n\nReasoning...\n
\n\nClean answer."]` + frame := ParseGradioStreamOutput(streamJSON) + if !frame.OK { + t.Fatalf("expected frame.OK to be true") + } + if frame.Reasoning != "Reasoning..." { + t.Errorf("expected frame.Reasoning='Reasoning...', got %q", frame.Reasoning) + } + if frame.Content != "Clean answer." { + t.Errorf("expected frame.Content='Clean answer.', got %q", frame.Content) + } +} + +func TestPausedSpaceDetectionAndImmediateExit(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("The space is paused")) + })) + defer ts.Close() + + makeReq := func() (*http.Request, error) { + return http.NewRequest("POST", ts.URL, bytes.NewBufferString("{}")) + } + + _, err := DoWithFibonacciRetry(http.DefaultClient, makeReq, 5) + if err == nil { + t.Fatalf("expected error from paused space, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "space is paused") { + t.Errorf("expected error to mention 'space is paused', got %v", err) + } + if callCount != 1 { + t.Errorf("expected exactly 1 attempt on paused space (no retries), got %d attempts", callCount) + } +} + +func TestOmniForgeSpaceInspectionAndPayload(t *testing.T) { + cfgJSON := `{ + "version": "6.26.0", + "mode": "blocks", + "title": "OmniForge AI", + "api_prefix": "/gradio_api", + "components": [ + { + "id": 7, + "type": "textbox", + "props": {"label": "Ask OmniForge", "value": ""} + }, + { + "id": 6, + "type": "chatbot", + "props": {"label": "OmniForge Conversation", "type": "messages"} + }, + { + "id": 13, + "type": "dropdown", + "props": { + "label": "Specialty mode", + "value": "General Engineering", + "choices": ["General Engineering", "Diagnostics and Repair"] + } + }, + { + "id": 14, + "type": "dropdown", + "props": { + "label": "Hugging Face model", + "value": "deepseek-ai/DeepSeek-V4.1-Flash", + "choices": [ + "deepseek-ai/DeepSeek-V4.1-Flash", + "openai/gpt-oss-120b", + "Qwen/Qwen2.5-7B-Instruct", + "meta-llama/Llama-3.1-8B-Instruct" + ] + } + }, + { + "id": 1, + "type": "state", + "props": {"name": "state"}, + "skip_api": true + }, + { + "id": 2, + "type": "browserstate", + "props": {"default_value": [], "name": "browserstate"}, + "skip_api": false + } + ], + "dependencies": [ + { + "id": 0, + "targets": [[10, "click"]], + "inputs": [7, 6, 13, 14, 1, 2], + "outputs": [6, 7], + "api_name": "chat", + "connection": "sse", + "protocol": "call_v2" + } + ] + }` + + infoJSON := `{ + "named_endpoints": { + "/chat": { + "parameters": [ + {"label": "message", "parameter_name": "message", "component": "Textbox"}, + {"label": "history", "parameter_name": "history", "component": "Chatbot"}, + {"label": "specialty_value", "parameter_name": "specialty_value", "component": "Dropdown"}, + {"label": "model_value", "parameter_name": "model_value", "component": "Dropdown"}, + {"label": "memory_value", "parameter_name": "memory_value", "component": "BrowserState", "parameter_default": []} + ] + } + } + }` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(cfgJSON)) + return + } + if strings.HasSuffix(r.URL.Path, "/info") { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(infoJSON)) + return + } + http.NotFound(w, r) + })) + defer ts.Close() + + disc, err := InspectSpace(http.DefaultClient, ts.URL, "gr2gw-test") + if err != nil { + t.Fatalf("InspectSpace failed: %v", err) + } + + if !disc.IsOmniForge { + t.Errorf("expected disc.IsOmniForge=true, got false") + } + if disc.TotalInputs != 6 { + t.Errorf("expected TotalInputs=6, got %d", disc.TotalInputs) + } + if disc.ModelIndex != 3 { + t.Errorf("expected ModelIndex=3, got %d", disc.ModelIndex) + } + if disc.HistoryIndex != 1 { + t.Errorf("expected HistoryIndex=1, got %d", disc.HistoryIndex) + } + if disc.MessageIndex != 0 { + t.Errorf("expected MessageIndex=0, got %d", disc.MessageIndex) + } + if disc.PrimaryModel != "deepseek-ai/DeepSeek-V4.1-Flash" { + t.Errorf("expected PrimaryModel='deepseek-ai/DeepSeek-V4.1-Flash', got %q", disc.PrimaryModel) + } + if !disc.MatchesModel("gpt-oss-120b") { + t.Errorf("expected MatchesModel('gpt-oss-120b') to be true") + } + if !disc.MatchesModel("openai/gpt-oss-120b") { + t.Errorf("expected MatchesModel('openai/gpt-oss-120b') to be true") + } + + gw := &GradioGateway{} + req := ChatCompletionRequest{ + Model: "gpt-oss-120b", + Messages: []ChatMessage{ + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there!"}, + {Role: "user", Content: "Tell me a joke"}, + }, + } + + payload, err := gw.BuildGradioPayload(disc, req) + if err != nil { + t.Fatalf("BuildGradioPayload failed: %v", err) + } + + if len(payload) != 6 { + t.Fatalf("expected payload len 6, got %d", len(payload)) + } + + // Model selection mapped to slot 3 + if payload[3] != "openai/gpt-oss-120b" { + t.Errorf("expected payload[3]='openai/gpt-oss-120b', got %v", payload[3]) + } + + // History slot mapped to slot 1: should be empty slice for OmniForge + if hist, ok := payload[1].([]map[string]interface{}); !ok || len(hist) != 0 { + t.Errorf("expected payload[1] to be empty slice, got %v", payload[1]) + } + + // Multi-turn history composed into promptMessageText in slot 0 + promptStr, ok := payload[0].(string) + if !ok { + t.Fatalf("expected payload[0] to be string, got %T", payload[0]) + } + if !strings.Contains(promptStr, "# Conversation History") || + !strings.Contains(promptStr, "User: Hello") || + !strings.Contains(promptStr, "Assistant: Hi there!") || + !strings.Contains(promptStr, "# Current Request\nUser: Tell me a joke") { + t.Errorf("promptStr missing multi-turn history: %s", promptStr) + } + + // Slot 5 is browserstate (memory_value) + if mem, ok := payload[5].([]interface{}); !ok && payload[5] == nil { + t.Errorf("expected payload[5] to be non-nil slice, got %v", payload[5]) + } else if ok && len(mem) != 0 { + t.Errorf("expected payload[5] to be empty slice, got %v", mem) + } +} + +func TestOmniForgeMockServerCompletion(t *testing.T) { + var receivedPayload map[string]interface{} + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gradio_api/call/v2/chat" && r.Method == "POST" { + json.NewDecoder(r.Body).Decode(&receivedPayload) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"event_id": "test-event-omni"}`)) + return + } + if (r.URL.Path == "/gradio_api/call/chat/test-event-omni" || r.URL.Path == "/gradio_api/call/v2/chat/test-event-omni") && r.Method == "GET" { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + flusher, _ := w.(http.Flusher) + + w.Write([]byte("event: generating\ndata: [[{\"role\": \"user\", \"metadata\": null, \"content\": [{\"text\": \"hello\", \"type\": \"text\"}], \"options\": null}, {\"role\": \"assistant\", \"metadata\": null, \"content\": [{\"text\": \"Thinking about jokeWhy did the chicken cross the road?\", \"type\": \"text\"}], \"options\": null}], \"\"]\n\n")) + if flusher != nil { + flusher.Flush() + } + + w.Write([]byte("event: complete\ndata: null\n\n")) + if flusher != nil { + flusher.Flush() + } + return + } + http.NotFound(w, r) + })) + defer ts.Close() + + disc := &SpaceDiscovery{ + SpaceURL: ts.URL, + APIPrefix: "/gradio_api", + CleanEndpoint: "chat", + Protocol: "call_v2", + IsOmniForge: true, + TotalInputs: 6, + MessageIndex: 0, + HistoryIndex: 1, + ModelIndex: 3, + PrimaryModel: "openai/gpt-oss-120b", + LastDiscovered: time.Now(), + ParamMappings: []SpaceParamMapping{ + {InputIndex: 0, ParamName: "message", ComponentType: "textbox"}, + {InputIndex: 1, ParamName: "history", ComponentType: "chatbot"}, + {InputIndex: 2, ParamName: "specialty_value", ComponentType: "dropdown", DefaultValue: "General Engineering"}, + {InputIndex: 3, ParamName: "model_value", ComponentType: "dropdown", Choices: []string{"openai/gpt-oss-120b"}, DefaultValue: "openai/gpt-oss-120b"}, + {InputIndex: 4, ParamName: "state", ParamType: "state", ComponentType: "state"}, + {InputIndex: 5, ParamName: "memory_value", ComponentType: "browserstate", DefaultValue: []interface{}{}}, + }, + } + + gw := NewGradioGateway(ts.URL, "", 10*time.Second) + gw.discoveries[ts.URL] = disc + + // 1. Non-streaming test + req := ChatCompletionRequest{ + Model: "openai/gpt-oss-120b", + Messages: []ChatMessage{ + {Role: "user", Content: "Tell me a joke"}, + }, + } + + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest("POST", "/v1/chat/completions", nil) + + 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()) + } + + // Check received payload by mock server + if receivedPayload["model_value"] != "openai/gpt-oss-120b" { + t.Errorf("expected model_value='openai/gpt-oss-120b', got %v", receivedPayload["model_value"]) + } + if receivedPayload["memory_value"] == nil { + t.Errorf("expected memory_value to be non-nil in v2 payload") + } + if _, hasState := receivedPayload["state"]; hasState { + t.Errorf("expected unexposed state component to NOT be in v2 payload") + } + + var resp ChatCompletionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(resp.Choices) == 0 { + t.Fatalf("expected choices in response") + } + + choice := resp.Choices[0] + if choice.Message.ReasoningContent != "Thinking about joke" { + t.Errorf("expected reasoning='Thinking about joke', got %q", choice.Message.ReasoningContent) + } + if choice.Message.Content != "Why did the chicken cross the road?" { + t.Errorf("expected content='Why did the chicken cross the road?', got %q", choice.Message.Content) + } + + // 2. Streaming test + reqStream := ChatCompletionRequest{ + Model: "openai/gpt-oss-120b", + Stream: true, + Messages: []ChatMessage{ + {Role: "user", Content: "Tell me a joke"}, + }, + } + + recStream := httptest.NewRecorder() + errStream := gw.ExecuteChatCompletion(recStream, httpReq, reqStream) + if errStream != nil { + t.Fatalf("streaming completion failed: %v", errStream) + } + + bodyStr := recStream.Body.String() + if !strings.Contains(bodyStr, "Thinking about joke") { + t.Errorf("streaming response missing reasoning token: %s", bodyStr) + } + if !strings.Contains(bodyStr, "Why did the chicken cross the road?") { + t.Errorf("streaming response missing content token: %s", bodyStr) + } +}