initial upl
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Makefile for hygate (Hunyuan AI Gateway)
|
||||
|
||||
hygate:
|
||||
go build -trimpath -ldflags="-s -w" -o bin/hygate .
|
||||
|
||||
all: hygate
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
.PHONY: all clean hygate
|
||||
@@ -0,0 +1,89 @@
|
||||
# Hygate
|
||||
|
||||
## About
|
||||
|
||||
Hygate is a standalone, single-binary gateway that exposes Tencent's Hunyuan 3 (hy3) Gradio space through an OpenAI-compatible API. It translates the standard `/v1/chat/completions` and `/v1/models` endpoints into Gradio's `/gradio_api/call/chat` request/stream protocol, so any OpenAI-compatible client can talk to Hunyuan 3 without modification.
|
||||
|
||||
## Features
|
||||
|
||||
- OpenAI-compatible chat completions (streaming and non-streaming)
|
||||
- Reasoning content passthrough (`reasoning_content`)
|
||||
- Tool/function calling, including incremental streaming of tool-call arguments
|
||||
- Fibonacci backoff retry on upstream calls
|
||||
- Configurable endpoint and User-Agent
|
||||
- No external dependencies (standard library only)
|
||||
|
||||
## Installation
|
||||
|
||||
Build from source with Go 1.26 or newer:
|
||||
|
||||
```
|
||||
make
|
||||
```
|
||||
|
||||
This produces `bin/hygate` binary for your architecture.
|
||||
|
||||
Install the latest release straight from the Git repository with `go install`:
|
||||
|
||||
```
|
||||
go install code.luxferre.top/luxferre/hygate@latest
|
||||
```
|
||||
|
||||
This fetches the module from `https://code.luxferre.top/luxferre/hygate.git` and places the `hygate` binary in `$(go env GOPATH)/bin`. Make sure that directory is on your `PATH`.
|
||||
|
||||
If you prefer to build from a local checkout instead:
|
||||
|
||||
```
|
||||
git clone https://code.luxferre.top/luxferre/hygate.git
|
||||
cd hygate
|
||||
go install .
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the gateway:
|
||||
|
||||
```
|
||||
./bin/hygate [-port 8080] [-endpoint https://tencent-hy3.hf.space]
|
||||
```
|
||||
|
||||
Available flags:
|
||||
|
||||
- `-port` — TCP port to listen on (default `8080`)
|
||||
- `-endpoint` — root URL of the Hunyuan Gradio space (default `https://tencent-hy3.hf.space`)
|
||||
- `-user-agent` / `-ua` — custom User-Agent sent to the upstream
|
||||
|
||||
Endpoints served:
|
||||
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
|
||||
Example request with curl:
|
||||
|
||||
```
|
||||
curl http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"hy3","messages":[{"role":"user","content":"Hello"}],"stream":false}'
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### Does this need an API key?
|
||||
|
||||
No. The gateway proxies directly to the public Gradio space and does not require authentication.
|
||||
|
||||
### Which models are reported?
|
||||
|
||||
A single model, `hy3`, is advertised via `/v1/models`.
|
||||
|
||||
### Are token usage counts returned?
|
||||
|
||||
No. The `usage` field is always zero, since the upstream Gradio API does not expose token counts.
|
||||
|
||||
### Can the upstream endpoint be changed?
|
||||
|
||||
Yes, with the `-endpoint` flag. Any HuggingFace Gradio space hosting the compatible `chat` endpoint will work.
|
||||
|
||||
## Credits
|
||||
|
||||
Created by Luxferre in 2026, released into the public domain with no warranties.
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHunyuanServiceEndpoint(t *testing.T) {
|
||||
svc := NewHunyuanService("https://example.com/gradio/")
|
||||
if svc.endpoint != "https://example.com/gradio" {
|
||||
t.Errorf("expected trailing slash stripped, got %q", svc.endpoint)
|
||||
}
|
||||
|
||||
models := svc.ListModels()
|
||||
if len(models) != 1 || models[0].ID != "hy3" {
|
||||
t.Fatalf("expected model ID 'hy3', got %v", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGradioStreamData(t *testing.T) {
|
||||
sampleJSON := `[["Hello world", "Thinking process...", null]]`
|
||||
c, r, tc, ok := parseGradioStreamData(sampleJSON)
|
||||
if !ok {
|
||||
t.Fatalf("expected parseGradioStreamData to succeed")
|
||||
}
|
||||
if c != "Hello world" {
|
||||
t.Errorf("content=%q, want 'Hello world'", c)
|
||||
}
|
||||
if r != "Thinking process..." {
|
||||
t.Errorf("reasoning=%q, want 'Thinking process...'", r)
|
||||
}
|
||||
if len(tc) != 0 {
|
||||
t.Errorf("expected 0 tool calls, got %d", len(tc))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHunyuanMockedCompletion(t *testing.T) {
|
||||
var receivedCallURL, receivedStreamURL string
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/gradio_api/call/chat") && r.Method == http.MethodPost {
|
||||
receivedCallURL = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"event_id":"test-event-123"}`)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.URL.Path, "/gradio_api/call/chat/test-event-123") && r.Method == http.MethodGet {
|
||||
receivedStreamURL = r.URL.Path
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: [[\"Mocked answer\", \"Mocked reasoning\", null]]\n\n")
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewHunyuanService(server.URL)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
|
||||
chatReq := ChatCompletionRequest{
|
||||
Model: "hy3",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
err := svc.Chat(rec, req, chatReq)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected Chat error: %v", err)
|
||||
}
|
||||
|
||||
if receivedCallURL != "/gradio_api/call/chat" {
|
||||
t.Errorf("expected call URL '/gradio_api/call/chat', got %q", receivedCallURL)
|
||||
}
|
||||
if receivedStreamURL != "/gradio_api/call/chat/test-event-123" {
|
||||
t.Errorf("expected stream URL '/gradio_api/call/chat/test-event-123', got %q", receivedStreamURL)
|
||||
}
|
||||
|
||||
var resp ChatCompletionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode completion response: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Choices) != 1 {
|
||||
t.Fatalf("expected 1 choice, got %d", len(resp.Choices))
|
||||
}
|
||||
if resp.Choices[0].Message.GetContentString() != "Mocked answer" {
|
||||
t.Errorf("content=%q, want 'Mocked answer'", resp.Choices[0].Message.GetContentString())
|
||||
}
|
||||
if resp.Choices[0].Message.ReasoningContent != "Mocked reasoning" {
|
||||
t.Errorf("reasoning=%q, want 'Mocked reasoning'", resp.Choices[0].Message.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGradioStreamDataWithToolCalls(t *testing.T) {
|
||||
sampleJSON := `[["", "Let me check the weather.", [{"id":"call_999","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]]]`
|
||||
c, r, tc, ok := parseGradioStreamData(sampleJSON)
|
||||
if !ok {
|
||||
t.Fatalf("expected parseGradioStreamData to succeed")
|
||||
}
|
||||
if c != "" {
|
||||
t.Errorf("content=%q, want empty", c)
|
||||
}
|
||||
if r != "Let me check the weather." {
|
||||
t.Errorf("reasoning=%q, want 'Let me check the weather.'", r)
|
||||
}
|
||||
if len(tc) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(tc))
|
||||
}
|
||||
if tc[0].ID != "call_999" || tc[0].Function.Name != "get_weather" || tc[0].Function.Arguments != `{"location":"Tokyo"}` {
|
||||
t.Errorf("unexpected tool call payload: %+v", tc[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHunyuanToolCallingNonStreaming(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/gradio_api/call/chat") && r.Method == http.MethodPost {
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
dataArr, _ := body["data"].([]interface{})
|
||||
if len(dataArr) < 9 || dataArr[8] == nil || dataArr[8] == "" {
|
||||
t.Errorf("expected functionsJSONStr in data[8], got %v", dataArr)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"event_id":"evt-tool-1"}`)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.URL.Path, "/gradio_api/call/chat/evt-tool-1") && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: [[\"\", \"I should call the search tool.\", [{\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"search\",\"arguments\":\"{\\\"query\\\":\\\"golang\\\"}\"}}]]]\n\n")
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewHunyuanService(server.URL)
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
|
||||
chatReq := ChatCompletionRequest{
|
||||
Model: "hy3",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "Search for golang news"},
|
||||
},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: map[string]interface{}{
|
||||
"name": "search",
|
||||
"description": "Search web",
|
||||
},
|
||||
},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
err := svc.Chat(rec, req, chatReq)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected Chat error: %v", err)
|
||||
}
|
||||
|
||||
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) != 1 {
|
||||
t.Fatalf("expected 1 choice, got %d", len(resp.Choices))
|
||||
}
|
||||
choice := resp.Choices[0]
|
||||
if choice.FinishReason != "tool_calls" {
|
||||
t.Errorf("finish_reason=%q, want 'tool_calls'", choice.FinishReason)
|
||||
}
|
||||
if choice.Message.ReasoningContent != "I should call the search tool." {
|
||||
t.Errorf("reasoning_content=%q, want 'I should call the search tool.'", choice.Message.ReasoningContent)
|
||||
}
|
||||
if len(choice.Message.ToolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(choice.Message.ToolCalls))
|
||||
}
|
||||
tc := choice.Message.ToolCalls[0]
|
||||
if tc.ID != "call_abc" || tc.Function.Name != "search" || tc.Function.Arguments != `{"query":"golang"}` {
|
||||
t.Errorf("unexpected tool call: %+v", tc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHunyuanReasoningAndToolCallingStreaming(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/gradio_api/call/chat") && r.Method == http.MethodPost {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"event_id":"evt-stream-1"}`)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.URL.Path, "/gradio_api/call/chat/evt-stream-1") && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
fmt.Fprint(w, "data: [[\"\", \"Step 1 reasoning.\", null]]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
fmt.Fprint(w, "data: [[\"Hello \", \"Step 1 reasoning. Step 2 reasoning.\", null]]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
fmt.Fprint(w, "data: [[\"Hello \", \"Step 1 reasoning. Step 2 reasoning.\", [{\"id\":\"call_xyz\",\"type\":\"function\",\"function\":{\"name\":\"calculator\",\"arguments\":\"{\\\"expr\\\":\\\"2+2\\\"}\"}}]]]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewHunyuanService(server.URL)
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
|
||||
chatReq := ChatCompletionRequest{
|
||||
Model: "hy3",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "user", Content: "Calculate 2+2"},
|
||||
},
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
err := svc.Chat(rec, req, chatReq)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected Chat error: %v", err)
|
||||
}
|
||||
|
||||
bodyStr := rec.Body.String()
|
||||
lines := strings.Split(bodyStr, "\n")
|
||||
|
||||
var receivedDeltas []StreamDelta
|
||||
var finishReasons []string
|
||||
var foundDone bool
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
payload := strings.TrimPrefix(line, "data: ")
|
||||
if payload == "[DONE]" {
|
||||
foundDone = true
|
||||
continue
|
||||
}
|
||||
var streamResp StreamResponse
|
||||
if err := json.Unmarshal([]byte(payload), &streamResp); err == nil && len(streamResp.Choices) > 0 {
|
||||
choice := streamResp.Choices[0]
|
||||
receivedDeltas = append(receivedDeltas, choice.Delta)
|
||||
if choice.FinishReason != nil {
|
||||
finishReasons = append(finishReasons, *choice.FinishReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDone {
|
||||
t.Errorf("expected 'data: [DONE]' in SSE stream")
|
||||
}
|
||||
|
||||
if len(receivedDeltas) < 1 || receivedDeltas[0].Role != "assistant" {
|
||||
t.Errorf("expected first delta to set role='assistant', got %+v", receivedDeltas[0])
|
||||
}
|
||||
|
||||
var combinedReasoning, combinedContent string
|
||||
var toolCallsEmitted []ToolCall
|
||||
for _, d := range receivedDeltas {
|
||||
combinedReasoning += d.ReasoningContent
|
||||
combinedContent += d.Content
|
||||
if len(d.ToolCalls) > 0 {
|
||||
toolCallsEmitted = append(toolCallsEmitted, d.ToolCalls...)
|
||||
}
|
||||
}
|
||||
|
||||
if combinedReasoning != "Step 1 reasoning. Step 2 reasoning." {
|
||||
t.Errorf("combined reasoning=%q, want 'Step 1 reasoning. Step 2 reasoning.'", combinedReasoning)
|
||||
}
|
||||
if combinedContent != "Hello " {
|
||||
t.Errorf("combined content=%q, want 'Hello '", combinedContent)
|
||||
}
|
||||
if len(toolCallsEmitted) == 0 {
|
||||
t.Fatalf("expected tool call deltas to be emitted")
|
||||
}
|
||||
if len(finishReasons) != 1 || finishReasons[0] != "tool_calls" {
|
||||
t.Errorf("finish_reasons=%v, want ['tool_calls']", finishReasons)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
// 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
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHunyuanService(endpoint string) *HunyuanService {
|
||||
cleanEndpoint := strings.TrimRight(endpoint, "/")
|
||||
return &HunyuanService{
|
||||
endpoint: cleanEndpoint,
|
||||
client: &http.Client{Timeout: 300 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HunyuanService) ListModels() []ModelItem {
|
||||
now := time.Now().Unix()
|
||||
return []ModelItem{
|
||||
{ID: "hy3", 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 = "hy3"
|
||||
}
|
||||
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")
|
||||
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)
|
||||
mux := NewMux(service)
|
||||
|
||||
fmt.Printf("hygate starting on port %s...\n", *portFlag)
|
||||
fmt.Printf("Target Endpoint: %s\n", service.endpoint)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user