Files
2026-07-31 10:37:20 +03:00

756 lines
21 KiB
Go

// hygate: Standalone OpenAI-compatible gateway for Hunyuan 3 Gradio spaces
// Created by Luxferre in 2026, released into the public domain
package main
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
var (
DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:153.0) Gecko/20100101 Firefox/153.0"
ConfiguredUserAgent string
)
// ---------------------------------------------------------------------------
// OpenAI API Data Structures
// ---------------------------------------------------------------------------
type ModelItem struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type ModelsResponse struct {
Object string `json:"object"`
Data []ModelItem `json:"data"`
}
type ToolCallFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ToolCall struct {
Index *int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function ToolCallFunction `json:"function"`
}
type Tool struct {
Type string `json:"type"`
Function interface{} `json:"function"`
}
type ChatMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
func (m *ChatMessage) GetContentString() string {
if m.Content == nil {
return ""
}
if str, ok := m.Content.(string); ok {
return str
}
if parts, ok := m.Content.([]interface{}); ok {
var sb strings.Builder
for _, p := range parts {
if str, ok := p.(string); ok {
sb.WriteString(str)
} else if itemMap, ok := p.(map[string]interface{}); ok {
if textVal, ok := itemMap["text"].(string); ok {
sb.WriteString(textVal)
}
}
}
return sb.String()
}
b, err := json.Marshal(m.Content)
if err == nil {
return string(b)
}
return fmt.Sprintf("%v", m.Content)
}
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
MaxCompletionTokens int `json:"max_completion_tokens"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
}
type ChatCompletionResponseChoice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatCompletionResponseChoice `json:"choices"`
Usage Usage `json:"usage"`
}
type StreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta StreamDelta `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
}
type StreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
}
type GradioJoinResponse struct {
EventID string `json:"event_id"`
}
// ---------------------------------------------------------------------------
// Helper Utilities
// ---------------------------------------------------------------------------
func GenerateUUID() string {
var b [16]byte
_, err := rand.Read(b[:])
if err != nil {
return "00000000-0000-4000-8000-000000000000"
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func FibonacciDelay(attempt int) time.Duration {
if attempt <= 0 {
return 1 * time.Second
}
a, b := 1, 1
for i := 1; i < attempt; i++ {
a, b = b, a+b
}
return time.Duration(a) * time.Second
}
func DoWithFibonacciRetry(client *http.Client, makeReq func() (*http.Request, error), maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
req, err := makeReq()
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err == nil && resp.StatusCode == http.StatusOK {
return resp, nil
}
if resp != nil {
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP status %d: %s", resp.StatusCode, string(respBody))
} else {
lastErr = err
}
if attempt < maxRetries {
delay := FibonacciDelay(attempt)
time.Sleep(delay)
}
}
return nil, fmt.Errorf("request failed after %d retries: %v", maxRetries, lastErr)
}
func EnableCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, api-key, X-User-Agent")
}
func ResolveMaxTokens(req ChatCompletionRequest) int {
mt := req.MaxTokens
if mt == 0 && req.MaxCompletionTokens > 0 {
mt = req.MaxCompletionTokens
}
if mt <= 0 {
mt = 131072
}
return mt
}
func EffectiveUserAgent(r *http.Request) string {
if r != nil {
if c := r.Header.Get("X-User-Agent"); c != "" {
return c
}
}
if ConfiguredUserAgent != "" {
return ConfiguredUserAgent
}
return DefaultUserAgent
}
// ---------------------------------------------------------------------------
// Response Framing
// ---------------------------------------------------------------------------
type FinalOutput struct {
Content interface{}
ReasoningContent string
ToolCalls []ToolCall
FinishReason string
}
func WriteCompletionResponse(w http.ResponseWriter, completionID string, created int64, model string, out FinalOutput) {
finish := out.FinishReason
if finish == "" {
finish = "stop"
}
resp := ChatCompletionResponse{
ID: completionID,
Object: "chat.completion",
Created: created,
Model: model,
Choices: []ChatCompletionResponseChoice{
{
Index: 0,
Message: ChatMessage{
Role: "assistant",
Content: out.Content,
ReasoningContent: out.ReasoningContent,
ToolCalls: out.ToolCalls,
},
FinishReason: finish,
},
},
Usage: Usage{
PromptTokens: 0,
CompletionTokens: 0,
TotalTokens: 0,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
type Streamer struct {
w http.ResponseWriter
flusher http.Flusher
id string
created int64
model string
}
func NewStreamer(w http.ResponseWriter, flusher http.Flusher, id string, created int64, model string) *Streamer {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
return &Streamer{w: w, flusher: flusher, id: id, created: created, model: model}
}
func (s *Streamer) Role() {
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{Role: "assistant"})
}
func (s *Streamer) Reasoning(text string) {
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{ReasoningContent: text})
}
func (s *Streamer) Content(text string) {
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{Content: text})
}
func (s *Streamer) ToolCallDelta(tc ToolCall) {
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{ToolCalls: []ToolCall{tc}})
}
func (s *Streamer) Finish(reason string) {
sendStreamChunk(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{}, &reason)
}
func (s *Streamer) Done() {
fmt.Fprintf(s.w, "data: [DONE]\n\n")
if s.flusher != nil {
s.flusher.Flush()
}
}
func sendStreamDelta(w http.ResponseWriter, flusher http.Flusher, completionID string, createdTime int64, modelName string, delta StreamDelta) {
sendStreamChunk(w, flusher, completionID, createdTime, modelName, delta, nil)
}
func sendStreamChunk(w http.ResponseWriter, flusher http.Flusher, completionID string, createdTime int64, modelName string, delta StreamDelta, finishReason *string) {
chunk := StreamResponse{
ID: completionID,
Object: "chat.completion.chunk",
Created: createdTime,
Model: modelName,
Choices: []StreamChoice{
{
Index: 0,
Delta: delta,
FinishReason: finishReason,
},
},
}
b, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", b)
if flusher != nil {
flusher.Flush()
}
}
// ---------------------------------------------------------------------------
// Hunyuan 3 Provider & Stream Parsing
// ---------------------------------------------------------------------------
func parseGradioStreamData(dataJSON string) (string, string, []ToolCall, bool) {
var raw []interface{}
if err := json.Unmarshal([]byte(dataJSON), &raw); err != nil || len(raw) == 0 {
return "", "", nil, false
}
inner, ok := raw[0].([]interface{})
if !ok || len(inner) < 2 {
return "", "", nil, false
}
contentStr, _ := inner[0].(string)
reasoningStr, _ := inner[1].(string)
var toolCalls []ToolCall
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 {
toolCalls = tcs
}
}
}
return contentStr, reasoningStr, toolCalls, true
}
type HunyuanService struct {
endpoint string
modelName string
client *http.Client
}
func NewHunyuanService(endpoint string, modelName string) *HunyuanService {
cleanEndpoint := strings.TrimRight(endpoint, "/")
if modelName == "" {
modelName = "hy3"
}
return &HunyuanService{
endpoint: cleanEndpoint,
modelName: modelName,
client: &http.Client{Timeout: 300 * time.Second},
}
}
func (s *HunyuanService) ListModels() []ModelItem {
now := time.Now().Unix()
return []ModelItem{
{ID: s.modelName, Object: "model", Created: now, OwnedBy: "hunyuan"},
}
}
func (s *HunyuanService) Chat(w http.ResponseWriter, r *http.Request, req ChatCompletionRequest) error {
modelName := req.Model
if modelName == "" {
modelName = s.modelName
}
maxTokens := ResolveMaxTokens(req)
var systemPromptStr string
var historyArray []map[string]interface{}
var messageStr string
var nonSystemMsgs []ChatMessage
for _, msg := range req.Messages {
cStr := msg.GetContentString()
if msg.Role == "system" && systemPromptStr == "" {
systemPromptStr = cStr
} else {
nonSystemMsgs = append(nonSystemMsgs, msg)
}
}
if len(nonSystemMsgs) > 0 {
for i := 0; i < len(nonSystemMsgs)-1; i++ {
m := nonSystemMsgs[i]
cStr := m.GetContentString()
item := map[string]interface{}{"role": m.Role}
switch m.Role {
case "assistant":
if cStr != "" {
item["content"] = cStr
} else {
item["content"] = nil
}
if m.ReasoningContent != "" {
item["reasoning_content"] = m.ReasoningContent
}
if len(m.ToolCalls) > 0 {
item["tool_calls"] = m.ToolCalls
}
case "tool", "function":
item["role"] = "tool"
item["content"] = cStr
toolID := m.ToolCallID
if toolID == "" {
toolID = m.Name
}
if toolID != "" {
item["tool_call_id"] = toolID
}
if m.Name != "" {
item["name"] = m.Name
}
default:
item["content"] = cStr
}
historyArray = append(historyArray, item)
}
lastMsg := nonSystemMsgs[len(nonSystemMsgs)-1]
lastContent := lastMsg.GetContentString()
if lastMsg.Role == "tool" || lastMsg.Role == "function" {
toolName := lastMsg.Name
if toolName == "" {
toolName = lastMsg.ToolCallID
}
if toolName != "" {
messageStr = fmt.Sprintf("Tool result for %s: %s", toolName, lastContent)
} else {
messageStr = lastContent
}
} else {
messageStr = lastContent
}
}
functionsJSONStr := ""
if len(req.Tools) > 0 {
b, err := json.Marshal(req.Tools)
if err == nil {
functionsJSONStr = string(b)
}
}
var tempVal interface{}
if req.Temperature != nil {
tempVal = *req.Temperature
}
var topPVal interface{}
if req.TopP != nil {
topPVal = *req.TopP
} else {
topPVal = 0
}
gradioData := []interface{}{
messageStr,
systemPromptStr,
historyArray,
"high",
tempVal,
maxTokens,
topPVal,
nil,
functionsJSONStr,
}
gradioPayload := map[string]interface{}{"data": gradioData}
jsonPayload, err := json.Marshal(gradioPayload)
if err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
effUA := EffectiveUserAgent(r)
callURL := s.endpoint + "/gradio_api/call/chat"
makeCallReq := func() (*http.Request, error) {
req, err := http.NewRequest("POST", callURL, bytes.NewBuffer(jsonPayload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", effUA)
return req, nil
}
resp, err := DoWithFibonacciRetry(s.client, makeCallReq, 5)
if err != nil {
return fmt.Errorf("upstream error: %w", err)
}
defer resp.Body.Close()
var joinRes GradioJoinResponse
if err := json.NewDecoder(resp.Body).Decode(&joinRes); err != nil || joinRes.EventID == "" {
return fmt.Errorf("failed to parse Gradio event ID")
}
streamURL := fmt.Sprintf("%s/gradio_api/call/chat/%s", s.endpoint, joinRes.EventID)
makeStreamReq := func() (*http.Request, error) {
req, err := http.NewRequest("GET", streamURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("User-Agent", effUA)
return req, nil
}
streamResp, err := DoWithFibonacciRetry(s.client, makeStreamReq, 5)
if err != nil {
return fmt.Errorf("upstream stream error: %w", err)
}
defer streamResp.Body.Close()
completionID := "chatcmpl-" + GenerateUUID()
createdTime := time.Now().Unix()
if !req.Stream {
reader := bufio.NewReader(streamResp.Body)
var finalContent, finalReasoning string
var finalToolCalls []ToolCall
for {
line, err := reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data: ") {
dataJSON := strings.TrimPrefix(line, "data: ")
if c, r, tc, ok := parseGradioStreamData(dataJSON); ok {
finalContent = c
finalReasoning = r
if len(tc) > 0 {
finalToolCalls = tc
}
}
}
}
finishReason := "stop"
var msgContent interface{} = finalContent
if len(finalToolCalls) > 0 {
finishReason = "tool_calls"
if finalContent == "" {
msgContent = nil
}
}
WriteCompletionResponse(w, completionID, createdTime, modelName, FinalOutput{
Content: msgContent,
ReasoningContent: finalReasoning,
ToolCalls: finalToolCalls,
FinishReason: finishReason,
})
return nil
}
flusher, _ := w.(http.Flusher)
streamer := NewStreamer(w, flusher, completionID, createdTime, modelName)
streamer.Role()
reader := bufio.NewReader(streamResp.Body)
var emittedContent, emittedReasoning string
lastToolCallArgs := map[string]string{}
emittedToolCallIDs := map[string]bool{}
for {
line, err := reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data: ") {
dataJSON := strings.TrimPrefix(line, "data: ")
if c, r, tc, ok := parseGradioStreamData(dataJSON); ok {
if len(r) > len(emittedReasoning) {
rDelta := r[len(emittedReasoning):]
emittedReasoning = r
streamer.Reasoning(rDelta)
}
if len(c) > len(emittedContent) {
cDelta := c[len(emittedContent):]
emittedContent = c
streamer.Content(cDelta)
}
if len(tc) > 0 {
for idx, t := range tc {
key := t.ID
if key == "" {
key = fmt.Sprintf("idx_%d", idx)
}
prevArgs := lastToolCallArgs[key]
currArgs := t.Function.Arguments
if !emittedToolCallIDs[key] {
emittedToolCallIDs[key] = true
lastToolCallArgs[key] = currArgs
tCopy := ToolCall{
Index: new(int),
ID: t.ID,
Type: "function",
Function: ToolCallFunction{Name: t.Function.Name, Arguments: currArgs},
}
*tCopy.Index = idx
streamer.ToolCallDelta(tCopy)
} else if len(currArgs) > len(prevArgs) {
argDelta := currArgs[len(prevArgs):]
lastToolCallArgs[key] = currArgs
tCopy := ToolCall{
Index: new(int),
Function: ToolCallFunction{Arguments: argDelta},
}
*tCopy.Index = idx
streamer.ToolCallDelta(tCopy)
}
}
}
}
}
}
if len(emittedToolCallIDs) > 0 {
streamer.Finish("tool_calls")
} else {
streamer.Finish("stop")
}
streamer.Done()
return nil
}
// ---------------------------------------------------------------------------
// HTTP Server Wiring & Main Entry Point
// ---------------------------------------------------------------------------
func ModelsHandler(service *HunyuanService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
EnableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
items := service.ListModels()
res := ModelsResponse{Object: "list", Data: items}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
}
func ChatHandler(service *HunyuanService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
EnableCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON payload: "+err.Error(), http.StatusBadRequest)
return
}
if err := service.Chat(w, r, req); err != nil {
if w.Header().Get("Content-Type") != "text/event-stream" {
http.Error(w, "Upstream error: "+err.Error(), http.StatusBadGateway)
}
}
}
}
func NewMux(service *HunyuanService) *http.ServeMux {
mux := http.NewServeMux()
mh := ModelsHandler(service)
ch := ChatHandler(service)
mux.HandleFunc("/models", mh)
mux.HandleFunc("/v1/models", mh)
mux.HandleFunc("/chat/completions", ch)
mux.HandleFunc("/v1/chat/completions", ch)
return mux
}
func main() {
portFlag := flag.String("port", "8080", "Port to listen on")
endpointFlag := flag.String("endpoint", "https://tencent-hy3.hf.space", "Root URL of the Hunyuan Gradio space")
modelFlag := flag.String("model", "hy3", "Exposed model name")
uaFlag := flag.String("user-agent", DefaultUserAgent, "Custom User-Agent header")
uaShortFlag := flag.String("ua", "", "Alias for -user-agent")
flag.Parse()
ConfiguredUserAgent = *uaFlag
if *uaShortFlag != "" {
ConfiguredUserAgent = *uaShortFlag
}
service := NewHunyuanService(*endpointFlag, *modelFlag)
mux := NewMux(service)
fmt.Printf("hygate starting on port %s...\n", *portFlag)
fmt.Printf("Target Endpoint: %s\n", service.endpoint)
fmt.Printf("Model Name: %s\n", service.modelName)
fmt.Printf("User-Agent: %s\n", ConfiguredUserAgent)
fmt.Printf("Endpoints:\n")
fmt.Printf(" GET http://localhost:%s/v1/models\n", *portFlag)
fmt.Printf(" POST http://localhost:%s/v1/chat/completions\n", *portFlag)
if err := http.ListenAndServe(":"+*portFlag, mux); err != nil {
fmt.Fprintf(os.Stderr, "server failed: %v\n", err)
os.Exit(1)
}
}