2252 lines
64 KiB
Go
2252 lines
64 KiB
Go
// groqqer: OpenAI-compatible LLM gateway for the Groq Streamlit space
|
|||
|
|
// Reverse engineers https://dromerosm-groq-chatbot.hf.space into an OpenAI proxy
|
||
|
|
// Created by Luxferre in 2026, released into the public domain
|
||
|
|
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/binary"
|
||
|
|
"encoding/json"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"os/signal"
|
||
|
|
"regexp"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||
|
|
DefaultTargetURL = "https://dromerosm-groq-chatbot.hf.space"
|
||
|
|
DefaultModel = "llama-3.3-70b-versatile"
|
||
|
|
cdpCmdCounter int64
|
||
|
|
)
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 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"`
|
||
|
|
ReasoningEffort string `json:"reasoning_effort,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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// UUID & Utility Functions
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func GenerateUUID() string {
|
||
|
|
var b [16]byte
|
||
|
|
_, _ = rand.Read(b[:])
|
||
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
||
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
||
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||
|
|
}
|
||
|
|
|
||
|
|
func getFreePort() (string, error) {
|
||
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
defer listener.Close()
|
||
|
|
return strconv.Itoa(listener.Addr().(*net.TCPAddr).Port), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func findFreeXDisplay() string {
|
||
|
|
for d := 100; d < 200; d++ {
|
||
|
|
lockFile := fmt.Sprintf("/tmp/.X%d-lock", d)
|
||
|
|
sockFile := fmt.Sprintf("/tmp/.X11-unix/X%d", d)
|
||
|
|
if _, err := os.Stat(lockFile); os.IsNotExist(err) {
|
||
|
|
if _, err2 := os.Stat(sockFile); os.IsNotExist(err2) {
|
||
|
|
return fmt.Sprintf(":%d", d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ":100"
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// SOCKS5 Proxy Client (RFC 1928 / RFC 1929)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
type SOCKS5Config struct {
|
||
|
|
Address string
|
||
|
|
Username string
|
||
|
|
Password string
|
||
|
|
}
|
||
|
|
|
||
|
|
func ParseSOCKS5URL(proxyURL string) (*SOCKS5Config, error) {
|
||
|
|
cleanURL := strings.TrimSpace(proxyURL)
|
||
|
|
if cleanURL == "" {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(cleanURL, "socks5://") {
|
||
|
|
cleanURL = strings.TrimPrefix(cleanURL, "socks5://")
|
||
|
|
} else if strings.HasPrefix(cleanURL, "socks5h://") {
|
||
|
|
cleanURL = strings.TrimPrefix(cleanURL, "socks5h://")
|
||
|
|
}
|
||
|
|
|
||
|
|
cfg := &SOCKS5Config{}
|
||
|
|
if atIdx := strings.LastIndex(cleanURL, "@"); atIdx != -1 {
|
||
|
|
userPass := cleanURL[:atIdx]
|
||
|
|
cfg.Address = cleanURL[atIdx+1:]
|
||
|
|
if colonIdx := strings.Index(userPass, ":"); colonIdx != -1 {
|
||
|
|
cfg.Username = userPass[:colonIdx]
|
||
|
|
cfg.Password = userPass[colonIdx+1:]
|
||
|
|
} else {
|
||
|
|
cfg.Username = userPass
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
cfg.Address = cleanURL
|
||
|
|
}
|
||
|
|
|
||
|
|
if !strings.Contains(cfg.Address, ":") {
|
||
|
|
cfg.Address = cfg.Address + ":1080"
|
||
|
|
}
|
||
|
|
return cfg, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func DialSOCKS5(ctx context.Context, proxyURL, targetAddr string) (net.Conn, error) {
|
||
|
|
cfg, err := ParseSOCKS5URL(proxyURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("invalid socks5 proxy configuration: %w", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
var d net.Dialer
|
||
|
|
return d.DialContext(ctx, "tcp", targetAddr)
|
||
|
|
}
|
||
|
|
|
||
|
|
var d net.Dialer
|
||
|
|
conn, err := d.DialContext(ctx, "tcp", cfg.Address)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to connect to socks5 proxy at %s: %w", cfg.Address, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
deadline, ok := ctx.Deadline()
|
||
|
|
if !ok {
|
||
|
|
deadline = time.Now().Add(30 * time.Second)
|
||
|
|
}
|
||
|
|
conn.SetDeadline(deadline)
|
||
|
|
defer conn.SetDeadline(time.Time{})
|
||
|
|
|
||
|
|
var greeting []byte
|
||
|
|
if cfg.Username != "" {
|
||
|
|
greeting = []byte{0x05, 0x02, 0x00, 0x02}
|
||
|
|
} else {
|
||
|
|
greeting = []byte{0x05, 0x01, 0x00}
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := conn.Write(greeting); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("failed to write socks5 greeting: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
resp := make([]byte, 2)
|
||
|
|
if _, err := io.ReadFull(conn, resp); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("failed to read socks5 greeting response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp[0] != 0x05 {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("unsupported socks version: 0x%02x", resp[0])
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp[1] == 0x02 {
|
||
|
|
if cfg.Username == "" {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("socks5 proxy requires authentication, but no credentials provided")
|
||
|
|
}
|
||
|
|
uLen := byte(len(cfg.Username))
|
||
|
|
pLen := byte(len(cfg.Password))
|
||
|
|
authReq := []byte{0x01, uLen}
|
||
|
|
authReq = append(authReq, []byte(cfg.Username)...)
|
||
|
|
authReq = append(authReq, pLen)
|
||
|
|
authReq = append(authReq, []byte(cfg.Password)...)
|
||
|
|
|
||
|
|
if _, err := conn.Write(authReq); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("failed to send socks5 authentication: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
authResp := make([]byte, 2)
|
||
|
|
if _, err := io.ReadFull(conn, authResp); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("failed to read socks5 auth response: %w", err)
|
||
|
|
}
|
||
|
|
if authResp[1] != 0x00 {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("socks5 authentication failed with status 0x%02x", authResp[1])
|
||
|
|
}
|
||
|
|
} else if resp[1] != 0x00 {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("socks5 proxy rejected authentication methods: 0x%02x", resp[1])
|
||
|
|
}
|
||
|
|
|
||
|
|
host, portStr, err := net.SplitHostPort(targetAddr)
|
||
|
|
if err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("invalid target address %s: %w", targetAddr, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
port, err := strconv.Atoi(portStr)
|
||
|
|
if err != nil || port < 1 || port > 65535 {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("invalid port in target address %s", targetAddr)
|
||
|
|
}
|
||
|
|
|
||
|
|
reqBuf := []byte{0x05, 0x01, 0x00}
|
||
|
|
ip := net.ParseIP(host)
|
||
|
|
if ip4 := ip.To4(); ip4 != nil {
|
||
|
|
reqBuf = append(reqBuf, 0x01)
|
||
|
|
reqBuf = append(reqBuf, ip4...)
|
||
|
|
} else if ip6 := ip.To16(); ip6 != nil {
|
||
|
|
reqBuf = append(reqBuf, 0x04)
|
||
|
|
reqBuf = append(reqBuf, ip6...)
|
||
|
|
} else {
|
||
|
|
reqBuf = append(reqBuf, 0x03, byte(len(host)))
|
||
|
|
reqBuf = append(reqBuf, []byte(host)...)
|
||
|
|
}
|
||
|
|
reqBuf = append(reqBuf, byte(port>>8), byte(port&0xFF))
|
||
|
|
|
||
|
|
if _, err := conn.Write(reqBuf); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("failed to send socks5 connect request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
respHdr := make([]byte, 4)
|
||
|
|
if _, err := io.ReadFull(conn, respHdr); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("failed to read socks5 connect response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if respHdr[1] != 0x00 {
|
||
|
|
conn.Close()
|
||
|
|
return nil, fmt.Errorf("socks5 connect failed with reply code 0x%02x", respHdr[1])
|
||
|
|
}
|
||
|
|
|
||
|
|
switch respHdr[3] {
|
||
|
|
case 0x01:
|
||
|
|
bnd := make([]byte, 6)
|
||
|
|
io.ReadFull(conn, bnd)
|
||
|
|
case 0x03:
|
||
|
|
lenBuf := make([]byte, 1)
|
||
|
|
io.ReadFull(conn, lenBuf)
|
||
|
|
bnd := make([]byte, int(lenBuf[0])+2)
|
||
|
|
io.ReadFull(conn, bnd)
|
||
|
|
case 0x04:
|
||
|
|
bnd := make([]byte, 18)
|
||
|
|
io.ReadFull(conn, bnd)
|
||
|
|
}
|
||
|
|
|
||
|
|
return conn, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tool and Message Processing
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func BuildToolInstruction(tools []Tool, toolChoice interface{}) string {
|
||
|
|
if len(tools) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
toolsBytes, _ := json.Marshal(tools)
|
||
|
|
var sb strings.Builder
|
||
|
|
sb.WriteString("\n\n# Tool Calling Instructions\nYou have access to the following functions:\n<tools>\n")
|
||
|
|
sb.WriteString(string(toolsBytes))
|
||
|
|
sb.WriteString("\n</tools>\n\nWhen you need to call a function, respond ONLY with a <tool_call> block formatted exactly as follows:\n<tool_call>\n{\"name\": \"<function-name>\", \"arguments\": {<args-json-object>}}\n</tool_call>\n\nDo not include conversational filler before or after the tool call.")
|
||
|
|
|
||
|
|
if toolChoice != nil {
|
||
|
|
if choiceStr, ok := toolChoice.(string); ok {
|
||
|
|
if choiceStr == "required" {
|
||
|
|
sb.WriteString("\n\nYou MUST invoke at least one tool from the list above.")
|
||
|
|
}
|
||
|
|
} else if choiceMap, ok := toolChoice.(map[string]interface{}); ok {
|
||
|
|
if fnMap, ok := choiceMap["function"].(map[string]interface{}); ok {
|
||
|
|
if fnName, ok := fnMap["name"].(string); ok && fnName != "" {
|
||
|
|
sb.WriteString(fmt.Sprintf("\n\nYou MUST call the function %q.", fnName))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
func FormatPrompt(req ChatCompletionRequest) string {
|
||
|
|
toolInstruction := BuildToolInstruction(req.Tools, req.ToolChoice)
|
||
|
|
|
||
|
|
var systemInstructions []string
|
||
|
|
var historyTurns []string
|
||
|
|
var currentTurn string
|
||
|
|
|
||
|
|
for i, msg := range req.Messages {
|
||
|
|
contentStr := msg.GetContentString()
|
||
|
|
switch msg.Role {
|
||
|
|
case "system":
|
||
|
|
if contentStr != "" {
|
||
|
|
systemInstructions = append(systemInstructions, contentStr)
|
||
|
|
}
|
||
|
|
case "assistant":
|
||
|
|
var sb strings.Builder
|
||
|
|
if contentStr != "" {
|
||
|
|
sb.WriteString(contentStr)
|
||
|
|
}
|
||
|
|
for _, tc := range msg.ToolCalls {
|
||
|
|
if sb.Len() > 0 {
|
||
|
|
sb.WriteString("\n")
|
||
|
|
}
|
||
|
|
args := tc.Function.Arguments
|
||
|
|
if strings.TrimSpace(args) == "" {
|
||
|
|
args = "{}"
|
||
|
|
}
|
||
|
|
sb.WriteString(fmt.Sprintf("<tool_call>\n{\"name\": %q, \"arguments\": %s}\n</tool_call>", tc.Function.Name, args))
|
||
|
|
}
|
||
|
|
historyTurns = append(historyTurns, "Assistant: "+sb.String())
|
||
|
|
case "tool", "function":
|
||
|
|
toolName := msg.Name
|
||
|
|
if toolName == "" {
|
||
|
|
toolName = msg.ToolCallID
|
||
|
|
}
|
||
|
|
var contentJSON []byte
|
||
|
|
if json.Valid([]byte(contentStr)) {
|
||
|
|
contentJSON = []byte(contentStr)
|
||
|
|
} else {
|
||
|
|
contentJSON, _ = json.Marshal(contentStr)
|
||
|
|
}
|
||
|
|
turnText := fmt.Sprintf("<tool_response>\n{\"name\": %q, \"content\": %s}\n</tool_response>", toolName, string(contentJSON))
|
||
|
|
if i == len(req.Messages)-1 {
|
||
|
|
currentTurn = turnText
|
||
|
|
} else {
|
||
|
|
historyTurns = append(historyTurns, "Tool Result: "+turnText)
|
||
|
|
}
|
||
|
|
case "user":
|
||
|
|
if i == len(req.Messages)-1 {
|
||
|
|
currentTurn = contentStr
|
||
|
|
} else {
|
||
|
|
historyTurns = append(historyTurns, "User: "+contentStr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var promptBuilder strings.Builder
|
||
|
|
|
||
|
|
if len(systemInstructions) > 0 || toolInstruction != "" {
|
||
|
|
promptBuilder.WriteString("[System Instructions]\n")
|
||
|
|
for _, sys := range systemInstructions {
|
||
|
|
promptBuilder.WriteString(sys)
|
||
|
|
promptBuilder.WriteString("\n")
|
||
|
|
}
|
||
|
|
if toolInstruction != "" {
|
||
|
|
promptBuilder.WriteString(strings.TrimSpace(toolInstruction))
|
||
|
|
promptBuilder.WriteString("\n")
|
||
|
|
}
|
||
|
|
promptBuilder.WriteString("\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(historyTurns) > 0 {
|
||
|
|
promptBuilder.WriteString("[Conversation History]\n")
|
||
|
|
for _, turn := range historyTurns {
|
||
|
|
promptBuilder.WriteString(turn)
|
||
|
|
promptBuilder.WriteString("\n")
|
||
|
|
}
|
||
|
|
promptBuilder.WriteString("\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
if currentTurn != "" {
|
||
|
|
if len(historyTurns) > 0 || len(systemInstructions) > 0 {
|
||
|
|
promptBuilder.WriteString("[User]\n")
|
||
|
|
}
|
||
|
|
promptBuilder.WriteString(currentTurn)
|
||
|
|
}
|
||
|
|
|
||
|
|
return strings.TrimSpace(promptBuilder.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
func cleanJSONBlock(input string) string {
|
||
|
|
s := strings.TrimSpace(input)
|
||
|
|
if strings.HasPrefix(s, "```") {
|
||
|
|
lines := strings.Split(s, "\n")
|
||
|
|
if len(lines) >= 2 {
|
||
|
|
if strings.HasPrefix(lines[len(lines)-1], "```") {
|
||
|
|
lines = lines[1 : len(lines)-1]
|
||
|
|
} else {
|
||
|
|
lines = lines[1:]
|
||
|
|
}
|
||
|
|
s = strings.TrimSpace(strings.Join(lines, "\n"))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func sanitizeJSONValue(v interface{}) interface{} {
|
||
|
|
switch val := v.(type) {
|
||
|
|
case string:
|
||
|
|
return strings.TrimSpace(val)
|
||
|
|
case map[string]interface{}:
|
||
|
|
cleanMap := make(map[string]interface{})
|
||
|
|
for k, childV := range val {
|
||
|
|
cleanKey := strings.TrimSpace(k)
|
||
|
|
cleanMap[cleanKey] = sanitizeJSONValue(childV)
|
||
|
|
}
|
||
|
|
return cleanMap
|
||
|
|
case []interface{}:
|
||
|
|
cleanSlice := make([]interface{}, len(val))
|
||
|
|
for i, childV := range val {
|
||
|
|
cleanSlice[i] = sanitizeJSONValue(childV)
|
||
|
|
}
|
||
|
|
return cleanSlice
|
||
|
|
default:
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var toolNameRegex = regexp.MustCompile(`"\s*(?:name|function|action|call)\s*"\s*:\s*"\s*([^"]+?)\s*"`)
|
||
|
|
|
||
|
|
func repairToolCallJSON(jsonStr string) (ToolCall, bool) {
|
||
|
|
nameMatch := toolNameRegex.FindStringSubmatch(jsonStr)
|
||
|
|
if len(nameMatch) < 2 {
|
||
|
|
return ToolCall{}, false
|
||
|
|
}
|
||
|
|
nameVal := strings.TrimSpace(nameMatch[1])
|
||
|
|
|
||
|
|
argsStr := "{}"
|
||
|
|
argsKwList := []string{`"arguments"`, `" parameters "`, `"arguments "`, `" parameters"`, `"parameters"`, `"args"`, `"input"`}
|
||
|
|
argsIdx := -1
|
||
|
|
for _, kw := range argsKwList {
|
||
|
|
idx := strings.Index(jsonStr, kw)
|
||
|
|
if idx >= 0 {
|
||
|
|
argsIdx = idx + len(kw)
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var targetStr string
|
||
|
|
if argsIdx >= 0 {
|
||
|
|
targetStr = strings.TrimSpace(jsonStr[argsIdx:])
|
||
|
|
if strings.HasPrefix(targetStr, ":") {
|
||
|
|
targetStr = strings.TrimSpace(targetStr[1:])
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
targetStr = jsonStr
|
||
|
|
}
|
||
|
|
|
||
|
|
if strings.HasPrefix(targetStr, "{") {
|
||
|
|
endIdx := strings.LastIndex(targetStr, "}")
|
||
|
|
if endIdx > 0 {
|
||
|
|
objCandidate := targetStr[:endIdx+1]
|
||
|
|
var testMap map[string]interface{}
|
||
|
|
if json.Unmarshal([]byte(objCandidate), &testMap) == nil {
|
||
|
|
b, _ := json.Marshal(sanitizeJSONValue(testMap))
|
||
|
|
return ToolCall{
|
||
|
|
ID: "call_" + GenerateUUID()[:8],
|
||
|
|
Type: "function",
|
||
|
|
Function: ToolCallFunction{
|
||
|
|
Name: nameVal,
|
||
|
|
Arguments: string(b),
|
||
|
|
},
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if strings.HasPrefix(targetStr, `"`) {
|
||
|
|
endIdx := strings.LastIndex(targetStr, `"`)
|
||
|
|
if endIdx > 0 {
|
||
|
|
val := strings.TrimSpace(targetStr[1:endIdx])
|
||
|
|
b, _ := json.Marshal(val)
|
||
|
|
argsStr = string(b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return ToolCall{
|
||
|
|
ID: "call_" + GenerateUUID()[:8],
|
||
|
|
Type: "function",
|
||
|
|
Function: ToolCallFunction{
|
||
|
|
Name: nameVal,
|
||
|
|
Arguments: argsStr,
|
||
|
|
},
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseSingleToolCall(jsonStr string) (ToolCall, bool) {
|
||
|
|
cleaned := cleanJSONBlock(jsonStr)
|
||
|
|
var raw map[string]interface{}
|
||
|
|
if err := json.Unmarshal([]byte(cleaned), &raw); err == nil {
|
||
|
|
sanitizedRaw, ok := sanitizeJSONValue(raw).(map[string]interface{})
|
||
|
|
if !ok {
|
||
|
|
sanitizedRaw = raw
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, wrapperKey := range []string{"function", "function_call", "tool_call"} {
|
||
|
|
if fnObj, ok := sanitizedRaw[wrapperKey].(map[string]interface{}); ok {
|
||
|
|
if nameVal, ok := fnObj["name"].(string); ok && nameVal != "" {
|
||
|
|
argsStr := "{}"
|
||
|
|
var argsVal interface{}
|
||
|
|
if a, hasA := fnObj["arguments"]; hasA {
|
||
|
|
argsVal = a
|
||
|
|
} else if p, hasP := fnObj["parameters"]; hasP {
|
||
|
|
argsVal = p
|
||
|
|
} else if args, hasArgs := fnObj["args"]; hasArgs {
|
||
|
|
argsVal = args
|
||
|
|
}
|
||
|
|
if argsVal != nil {
|
||
|
|
if s, isStr := argsVal.(string); isStr {
|
||
|
|
var innerObj interface{}
|
||
|
|
if json.Unmarshal([]byte(s), &innerObj) == nil {
|
||
|
|
b, _ := json.Marshal(sanitizeJSONValue(innerObj))
|
||
|
|
argsStr = string(b)
|
||
|
|
} else {
|
||
|
|
argsStr = strings.TrimSpace(s)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
b, _ := json.Marshal(sanitizeJSONValue(argsVal))
|
||
|
|
argsStr = string(b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ToolCall{
|
||
|
|
ID: "call_" + GenerateUUID()[:8],
|
||
|
|
Type: "function",
|
||
|
|
Function: ToolCallFunction{
|
||
|
|
Name: nameVal,
|
||
|
|
Arguments: argsStr,
|
||
|
|
},
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
nameVal := ""
|
||
|
|
for _, key := range []string{"name", "function", "action", "call"} {
|
||
|
|
if n, ok := sanitizedRaw[key].(string); ok && n != "" {
|
||
|
|
nameVal = n
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if nameVal != "" {
|
||
|
|
argsStr := "{}"
|
||
|
|
var argsVal interface{}
|
||
|
|
for _, key := range []string{"arguments", "parameters", "args", "input"} {
|
||
|
|
if a, ok := sanitizedRaw[key]; ok {
|
||
|
|
argsVal = a
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if argsVal != nil {
|
||
|
|
if s, isStr := argsVal.(string); isStr {
|
||
|
|
var innerObj interface{}
|
||
|
|
if json.Unmarshal([]byte(s), &innerObj) == nil {
|
||
|
|
b, _ := json.Marshal(sanitizeJSONValue(innerObj))
|
||
|
|
argsStr = string(b)
|
||
|
|
} else {
|
||
|
|
argsStr = strings.TrimSpace(s)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
b, _ := json.Marshal(sanitizeJSONValue(argsVal))
|
||
|
|
argsStr = string(b)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
argsMap := make(map[string]interface{})
|
||
|
|
for k, v := range sanitizedRaw {
|
||
|
|
if k != "name" && k != "function" && k != "type" && k != "action" && k != "call" {
|
||
|
|
argsMap[k] = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(argsMap) > 0 {
|
||
|
|
b, _ := json.Marshal(sanitizeJSONValue(argsMap))
|
||
|
|
argsStr = string(b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ToolCall{
|
||
|
|
ID: "call_" + GenerateUUID()[:8],
|
||
|
|
Type: "function",
|
||
|
|
Function: ToolCallFunction{
|
||
|
|
Name: nameVal,
|
||
|
|
Arguments: argsStr,
|
||
|
|
},
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return repairToolCallJSON(cleaned)
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseXMLToolCall(block string) (ToolCall, bool) {
|
||
|
|
inner := strings.TrimSpace(block)
|
||
|
|
if strings.HasPrefix(inner, "<tool_call>") {
|
||
|
|
inner = strings.TrimPrefix(inner, "<tool_call>")
|
||
|
|
}
|
||
|
|
if strings.HasSuffix(inner, "</tool_call>") {
|
||
|
|
inner = strings.TrimSuffix(inner, "</tool_call>")
|
||
|
|
}
|
||
|
|
inner = cleanJSONBlock(inner)
|
||
|
|
|
||
|
|
if tc, ok := parseSingleToolCall(inner); ok {
|
||
|
|
return tc, true
|
||
|
|
}
|
||
|
|
|
||
|
|
var fnName string
|
||
|
|
if strings.Contains(inner, "<name>") && strings.Contains(inner, "</name>") {
|
||
|
|
nStart := strings.Index(inner, "<name>") + len("<name>")
|
||
|
|
nEnd := strings.Index(inner, "</name>")
|
||
|
|
if nStart < nEnd {
|
||
|
|
fnName = strings.TrimSpace(inner[nStart:nEnd])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var argsStr string
|
||
|
|
if strings.Contains(inner, "<arguments>") && strings.Contains(inner, "</arguments>") {
|
||
|
|
aStart := strings.Index(inner, "<arguments>") + len("<arguments>")
|
||
|
|
aEnd := strings.Index(inner, "</arguments>")
|
||
|
|
if aStart < aEnd {
|
||
|
|
argsStr = strings.TrimSpace(inner[aStart:aEnd])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if fnName != "" {
|
||
|
|
if argsStr == "" {
|
||
|
|
argsStr = "{}"
|
||
|
|
}
|
||
|
|
return ToolCall{
|
||
|
|
ID: "call_" + GenerateUUID()[:8],
|
||
|
|
Type: "function",
|
||
|
|
Function: ToolCallFunction{
|
||
|
|
Name: fnName,
|
||
|
|
Arguments: argsStr,
|
||
|
|
},
|
||
|
|
}, true
|
||
|
|
}
|
||
|
|
|
||
|
|
return ToolCall{}, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func ExtractToolCallBlocks(content string) (blocks []string, remaining string) {
|
||
|
|
s := content
|
||
|
|
remaining = content
|
||
|
|
|
||
|
|
for strings.Contains(s, "<tool_call>") {
|
||
|
|
sIdx := strings.Index(s, "<tool_call>")
|
||
|
|
rest := s[sIdx+len("<tool_call>"):]
|
||
|
|
|
||
|
|
relNextSIdx := strings.Index(rest, "<tool_call>")
|
||
|
|
var nextSIdx int
|
||
|
|
if relNextSIdx != -1 {
|
||
|
|
nextSIdx = sIdx + len("<tool_call>") + relNextSIdx
|
||
|
|
} else {
|
||
|
|
nextSIdx = -1
|
||
|
|
}
|
||
|
|
|
||
|
|
relEIdx := strings.Index(rest, "</tool_call>")
|
||
|
|
var eIdx int
|
||
|
|
if relEIdx != -1 {
|
||
|
|
eIdx = sIdx + len("<tool_call>") + relEIdx
|
||
|
|
} else {
|
||
|
|
eIdx = -1
|
||
|
|
}
|
||
|
|
|
||
|
|
var blockText string
|
||
|
|
var blockEndPos int
|
||
|
|
|
||
|
|
if eIdx != -1 && (nextSIdx == -1 || eIdx < nextSIdx) {
|
||
|
|
blockEndPos = eIdx + len("</tool_call>")
|
||
|
|
blockText = s[sIdx:blockEndPos]
|
||
|
|
s = s[blockEndPos:]
|
||
|
|
} else if nextSIdx != -1 {
|
||
|
|
blockEndPos = nextSIdx
|
||
|
|
blockText = s[sIdx:blockEndPos]
|
||
|
|
s = s[blockEndPos:]
|
||
|
|
} else {
|
||
|
|
blockText = s[sIdx:]
|
||
|
|
s = ""
|
||
|
|
}
|
||
|
|
|
||
|
|
blocks = append(blocks, blockText)
|
||
|
|
}
|
||
|
|
|
||
|
|
for strings.Contains(remaining, "<tool_call>") {
|
||
|
|
st := strings.Index(remaining, "<tool_call>")
|
||
|
|
rest := remaining[st+len("<tool_call>"):]
|
||
|
|
|
||
|
|
relNext := strings.Index(rest, "<tool_call>")
|
||
|
|
var nextSt int
|
||
|
|
if relNext != -1 {
|
||
|
|
nextSt = st + len("<tool_call>") + relNext
|
||
|
|
} else {
|
||
|
|
nextSt = -1
|
||
|
|
}
|
||
|
|
|
||
|
|
relEn := strings.Index(rest, "</tool_call>")
|
||
|
|
var en int
|
||
|
|
if relEn != -1 {
|
||
|
|
en = st + len("<tool_call>") + relEn
|
||
|
|
} else {
|
||
|
|
en = -1
|
||
|
|
}
|
||
|
|
|
||
|
|
if en != -1 && (nextSt == -1 || en < nextSt) {
|
||
|
|
remaining = strings.TrimSpace(remaining[:st] + remaining[en+len("</tool_call>"):])
|
||
|
|
} else if nextSt != -1 {
|
||
|
|
remaining = strings.TrimSpace(remaining[:st] + remaining[nextSt:])
|
||
|
|
} else {
|
||
|
|
remaining = strings.TrimSpace(remaining[:st])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return blocks, remaining
|
||
|
|
}
|
||
|
|
|
||
|
|
func DetectToolCalls(content string) ([]ToolCall, string, bool) {
|
||
|
|
blocks, remaining := ExtractToolCallBlocks(content)
|
||
|
|
var calls []ToolCall
|
||
|
|
|
||
|
|
for _, block := range blocks {
|
||
|
|
if toolCall, ok := parseXMLToolCall(block); ok {
|
||
|
|
calls = append(calls, toolCall)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(calls) > 0 {
|
||
|
|
return calls, remaining, true
|
||
|
|
}
|
||
|
|
|
||
|
|
if tc, ok := parseSingleToolCall(strings.TrimSpace(content)); ok {
|
||
|
|
return []ToolCall{tc}, "", true
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil, content, false
|
||
|
|
}
|
||
|
|
|
||
|
|
func ExtractThinking(content string) (string, string) {
|
||
|
|
if strings.Contains(content, "<think>") && strings.Contains(content, "</think>") {
|
||
|
|
start := strings.Index(content, "<think>")
|
||
|
|
end := strings.Index(content, "</think>")
|
||
|
|
if start < end {
|
||
|
|
reasoning := content[start+len("<think>") : end]
|
||
|
|
rem := content[:start] + content[end+len("</think>"):]
|
||
|
|
rem = strings.TrimPrefix(rem, "\n\n")
|
||
|
|
rem = strings.TrimPrefix(rem, "\n")
|
||
|
|
return strings.TrimSpace(rem), strings.TrimSpace(reasoning)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return content, ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Streaming Filters & Framing
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
type StreamThinkingFilter struct {
|
||
|
|
inThinking bool
|
||
|
|
buf string
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewStreamThinkingFilter() *StreamThinkingFilter {
|
||
|
|
return &StreamThinkingFilter{}
|
||
|
|
}
|
||
|
|
|
||
|
|
func hasPrefixOf(target string, prefixes []string) int {
|
||
|
|
for _, p := range prefixes {
|
||
|
|
if strings.HasSuffix(target, p) {
|
||
|
|
return len(p)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *StreamThinkingFilter) Feed(chunk string, onContent func(string), onReasoning func(string)) {
|
||
|
|
f.buf += chunk
|
||
|
|
thinkStartTag := "<think>"
|
||
|
|
thinkEndTag := "</think>"
|
||
|
|
|
||
|
|
thinkStartPrefixes := []string{"<", "<t", "<th", "<thi", "<thin", "<think"}
|
||
|
|
thinkEndPrefixes := []string{"<", "</", "</t", "</th", "</thi", "</thin", "</think"}
|
||
|
|
|
||
|
|
for len(f.buf) > 0 {
|
||
|
|
if !f.inThinking {
|
||
|
|
if idx := strings.Index(f.buf, thinkStartTag); idx != -1 {
|
||
|
|
before := f.buf[:idx]
|
||
|
|
if before != "" {
|
||
|
|
onContent(before)
|
||
|
|
}
|
||
|
|
f.inThinking = true
|
||
|
|
f.buf = f.buf[idx+len(thinkStartTag):]
|
||
|
|
} else if matchLen := hasPrefixOf(f.buf, thinkStartPrefixes); matchLen > 0 {
|
||
|
|
safe := f.buf[:len(f.buf)-matchLen]
|
||
|
|
if safe != "" {
|
||
|
|
onContent(safe)
|
||
|
|
}
|
||
|
|
f.buf = f.buf[len(f.buf)-matchLen:]
|
||
|
|
break
|
||
|
|
} else {
|
||
|
|
onContent(f.buf)
|
||
|
|
f.buf = ""
|
||
|
|
break
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if idx := strings.Index(f.buf, thinkEndTag); idx != -1 {
|
||
|
|
before := f.buf[:idx]
|
||
|
|
if before != "" {
|
||
|
|
onReasoning(before)
|
||
|
|
}
|
||
|
|
f.inThinking = false
|
||
|
|
f.buf = f.buf[idx+len(thinkEndTag):]
|
||
|
|
f.buf = strings.TrimPrefix(f.buf, "\n\n")
|
||
|
|
f.buf = strings.TrimPrefix(f.buf, "\n")
|
||
|
|
} else if matchLen := hasPrefixOf(f.buf, thinkEndPrefixes); matchLen > 0 {
|
||
|
|
safe := f.buf[:len(f.buf)-matchLen]
|
||
|
|
if safe != "" {
|
||
|
|
onReasoning(safe)
|
||
|
|
}
|
||
|
|
f.buf = f.buf[len(f.buf)-matchLen:]
|
||
|
|
break
|
||
|
|
} else {
|
||
|
|
onReasoning(f.buf)
|
||
|
|
f.buf = ""
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *StreamThinkingFilter) Flush(onContent func(string), onReasoning func(string)) {
|
||
|
|
if len(f.buf) > 0 {
|
||
|
|
if f.inThinking {
|
||
|
|
onReasoning(f.buf)
|
||
|
|
} else {
|
||
|
|
onContent(f.buf)
|
||
|
|
}
|
||
|
|
f.buf = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamToolCallFilter struct {
|
||
|
|
inToolCall bool
|
||
|
|
buf string
|
||
|
|
toolCallBuf string
|
||
|
|
toolIndex int
|
||
|
|
emittedCall bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewStreamToolCallFilter() *StreamToolCallFilter {
|
||
|
|
return &StreamToolCallFilter{}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onToolCall func(ToolCall)) {
|
||
|
|
f.buf += chunk
|
||
|
|
toolStartTag := "<tool_call>"
|
||
|
|
toolEndTag := "</tool_call>"
|
||
|
|
|
||
|
|
startPrefixes := []string{"<", "<t", "<to", "<too", "<tool", "<tool_", "<tool_c", "<tool_ca", "<tool_cal", "<tool_call"}
|
||
|
|
endPrefixes := []string{"<", "</", "</t", "</to", "</too", "</tool", "</tool_", "</tool_c", "</tool_ca", "</tool_cal", "</tool_call"}
|
||
|
|
|
||
|
|
for len(f.buf) > 0 {
|
||
|
|
if !f.inToolCall {
|
||
|
|
if idx := strings.Index(f.buf, toolStartTag); idx != -1 {
|
||
|
|
before := f.buf[:idx]
|
||
|
|
if before != "" {
|
||
|
|
onContent(before)
|
||
|
|
}
|
||
|
|
f.inToolCall = true
|
||
|
|
f.buf = f.buf[idx+len(toolStartTag):]
|
||
|
|
} else if matchLen := hasPrefixOf(f.buf, startPrefixes); matchLen > 0 {
|
||
|
|
safe := f.buf[:len(f.buf)-matchLen]
|
||
|
|
if safe != "" {
|
||
|
|
onContent(safe)
|
||
|
|
}
|
||
|
|
f.buf = f.buf[len(f.buf)-matchLen:]
|
||
|
|
break
|
||
|
|
} else {
|
||
|
|
onContent(f.buf)
|
||
|
|
f.buf = ""
|
||
|
|
break
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if idx := strings.Index(f.buf, toolEndTag); idx != -1 {
|
||
|
|
f.toolCallBuf += f.buf[:idx]
|
||
|
|
f.buf = f.buf[idx+len(toolEndTag):]
|
||
|
|
f.inToolCall = false
|
||
|
|
|
||
|
|
if tc, ok := parseSingleToolCall(f.toolCallBuf); ok {
|
||
|
|
idxCopy := f.toolIndex
|
||
|
|
tc.Index = &idxCopy
|
||
|
|
f.toolIndex++
|
||
|
|
f.emittedCall = true
|
||
|
|
onToolCall(tc)
|
||
|
|
} else if tc2, ok2 := parseXMLToolCall("<tool_call>" + f.toolCallBuf + "</tool_call>"); ok2 {
|
||
|
|
idxCopy := f.toolIndex
|
||
|
|
tc2.Index = &idxCopy
|
||
|
|
f.toolIndex++
|
||
|
|
f.emittedCall = true
|
||
|
|
onToolCall(tc2)
|
||
|
|
} else {
|
||
|
|
onContent("<tool_call>" + f.toolCallBuf + "</tool_call>")
|
||
|
|
}
|
||
|
|
f.toolCallBuf = ""
|
||
|
|
} else if matchLen := hasPrefixOf(f.buf, endPrefixes); matchLen > 0 {
|
||
|
|
safe := f.buf[:len(f.buf)-matchLen]
|
||
|
|
f.toolCallBuf += safe
|
||
|
|
f.buf = f.buf[len(f.buf)-matchLen:]
|
||
|
|
break
|
||
|
|
} else {
|
||
|
|
f.toolCallBuf += f.buf
|
||
|
|
f.buf = ""
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *StreamToolCallFilter) Flush(onContent func(string), onToolCall func(ToolCall)) {
|
||
|
|
if f.inToolCall && len(f.toolCallBuf) > 0 {
|
||
|
|
if tc, ok := parseSingleToolCall(f.toolCallBuf); ok {
|
||
|
|
idxCopy := f.toolIndex
|
||
|
|
tc.Index = &idxCopy
|
||
|
|
f.emittedCall = true
|
||
|
|
onToolCall(tc)
|
||
|
|
} else if tc2, ok2 := parseXMLToolCall("<tool_call>" + f.toolCallBuf + "</tool_call>"); ok2 {
|
||
|
|
idxCopy := f.toolIndex
|
||
|
|
tc2.Index = &idxCopy
|
||
|
|
f.emittedCall = true
|
||
|
|
onToolCall(tc2)
|
||
|
|
} else {
|
||
|
|
onContent("<tool_call>" + f.toolCallBuf)
|
||
|
|
}
|
||
|
|
f.toolCallBuf = ""
|
||
|
|
}
|
||
|
|
if len(f.buf) > 0 {
|
||
|
|
onContent(f.buf)
|
||
|
|
f.buf = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
if text != "" {
|
||
|
|
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{ReasoningContent: text}, "")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Streamer) Content(text string) {
|
||
|
|
if text != "" {
|
||
|
|
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{Content: text}, "")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Streamer) ToolCall(tc ToolCall) {
|
||
|
|
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{ToolCalls: []ToolCall{tc}}, "")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Streamer) Finish(reason string) {
|
||
|
|
sendStreamDelta(s.w, s.flusher, s.id, s.created, s.model, StreamDelta{}, reason)
|
||
|
|
fmt.Fprintf(s.w, "data: [DONE]\n\n")
|
||
|
|
if s.flusher != nil {
|
||
|
|
s.flusher.Flush()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func sendStreamDelta(w http.ResponseWriter, flusher http.Flusher, id string, created int64, model string, delta StreamDelta, finishReason string) {
|
||
|
|
chunk := StreamResponse{
|
||
|
|
ID: id,
|
||
|
|
Object: "chat.completion.chunk",
|
||
|
|
Created: created,
|
||
|
|
Model: model,
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func WriteCompletionResponse(w http.ResponseWriter, completionID string, created int64, model string, content string, reasoning string, toolCalls []ToolCall, finishReason string) {
|
||
|
|
if finishReason == "" {
|
||
|
|
finishReason = "stop"
|
||
|
|
}
|
||
|
|
var contentVal interface{} = content
|
||
|
|
if len(toolCalls) > 0 && strings.TrimSpace(content) == "" {
|
||
|
|
contentVal = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
resp := ChatCompletionResponse{
|
||
|
|
ID: completionID,
|
||
|
|
Object: "chat.completion",
|
||
|
|
Created: created,
|
||
|
|
Model: model,
|
||
|
|
Choices: []ChatCompletionResponseChoice{
|
||
|
|
{
|
||
|
|
Index: 0,
|
||
|
|
Message: ChatMessage{
|
||
|
|
Role: "assistant",
|
||
|
|
Content: contentVal,
|
||
|
|
ReasoningContent: reasoning,
|
||
|
|
ToolCalls: toolCalls,
|
||
|
|
},
|
||
|
|
FinishReason: finishReason,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
Usage: Usage{
|
||
|
|
PromptTokens: 0,
|
||
|
|
CompletionTokens: 0,
|
||
|
|
TotalTokens: 0,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
json.NewEncoder(w).Encode(resp)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Pure Go Chromium CDP Engine & BrowserBridge
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func findChromiumBinary(customPath string) string {
|
||
|
|
if customPath != "" {
|
||
|
|
if _, err := exec.LookPath(customPath); err == nil {
|
||
|
|
return customPath
|
||
|
|
}
|
||
|
|
}
|
||
|
|
candidates := []string{"chromium", "google-chrome", "chromium-browser", "chrome"}
|
||
|
|
for _, c := range candidates {
|
||
|
|
if p, err := exec.LookPath(c); err == nil {
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func dialCDPWebSocket(wsURL string) (net.Conn, *bufio.Reader, error) {
|
||
|
|
parts := strings.TrimPrefix(wsURL, "ws://")
|
||
|
|
slashIdx := strings.Index(parts, "/")
|
||
|
|
if slashIdx == -1 {
|
||
|
|
return nil, nil, fmt.Errorf("invalid websocket url: %s", wsURL)
|
||
|
|
}
|
||
|
|
host := parts[:slashIdx]
|
||
|
|
path := parts[slashIdx:]
|
||
|
|
|
||
|
|
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
nonce := make([]byte, 16)
|
||
|
|
_, _ = rand.Read(nonce)
|
||
|
|
secKey := base64.StdEncoding.EncodeToString(nonce)
|
||
|
|
|
||
|
|
req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n", path, host, secKey)
|
||
|
|
if _, err := conn.Write([]byte(req)); err != nil {
|
||
|
|
conn.Close()
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
reader := bufio.NewReader(conn)
|
||
|
|
statusLine, err := reader.ReadString('\n')
|
||
|
|
if err != nil || !strings.Contains(statusLine, "101") {
|
||
|
|
conn.Close()
|
||
|
|
return nil, nil, fmt.Errorf("websocket upgrade failed: %s", statusLine)
|
||
|
|
}
|
||
|
|
|
||
|
|
for {
|
||
|
|
line, err := reader.ReadString('\n')
|
||
|
|
if err != nil || strings.TrimSpace(line) == "" {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return conn, reader, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func sendWSFrame(conn net.Conn, payload []byte) error {
|
||
|
|
var header []byte
|
||
|
|
header = append(header, 0x81)
|
||
|
|
|
||
|
|
length := len(payload)
|
||
|
|
var maskKey [4]byte
|
||
|
|
_, _ = rand.Read(maskKey[:])
|
||
|
|
|
||
|
|
if length < 126 {
|
||
|
|
header = append(header, byte(length)|0x80)
|
||
|
|
} else if length < 65536 {
|
||
|
|
header = append(header, 126|0x80)
|
||
|
|
header = append(header, byte(length>>8), byte(length&0xff))
|
||
|
|
} else {
|
||
|
|
header = append(header, 127|0x80)
|
||
|
|
for i := 7; i >= 0; i-- {
|
||
|
|
header = append(header, byte((length>>(i*8))&0xff))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
header = append(header, maskKey[:]...)
|
||
|
|
masked := make([]byte, length)
|
||
|
|
for i := 0; i < length; i++ {
|
||
|
|
masked[i] = payload[i] ^ maskKey[i%4]
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err := conn.Write(append(header, masked...))
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func readWSFrame(conn net.Conn, reader *bufio.Reader) ([]byte, error) {
|
||
|
|
conn.SetReadDeadline(time.Now().Add(35 * time.Second))
|
||
|
|
b1, err := reader.ReadByte()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
opcode := b1 & 0x0f
|
||
|
|
if opcode == 0x08 {
|
||
|
|
return nil, fmt.Errorf("websocket closed by server")
|
||
|
|
}
|
||
|
|
|
||
|
|
b2, err := reader.ReadByte()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
isMasked := (b2 & 0x80) != 0
|
||
|
|
length := int(b2 & 0x7f)
|
||
|
|
|
||
|
|
if length == 126 {
|
||
|
|
var extLen uint16
|
||
|
|
if err := binary.Read(reader, binary.BigEndian, &extLen); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
length = int(extLen)
|
||
|
|
} else if length == 127 {
|
||
|
|
var extLen uint64
|
||
|
|
if err := binary.Read(reader, binary.BigEndian, &extLen); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
length = int(extLen)
|
||
|
|
}
|
||
|
|
|
||
|
|
var maskKey [4]byte
|
||
|
|
if isMasked {
|
||
|
|
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
payload := make([]byte, length)
|
||
|
|
if _, err := io.ReadFull(reader, payload); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if isMasked {
|
||
|
|
for i := 0; i < length; i++ {
|
||
|
|
payload[i] ^= maskKey[i%4]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return payload, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func sendCDPCommand(conn net.Conn, reader *bufio.Reader, method string, params map[string]interface{}) (map[string]interface{}, error) {
|
||
|
|
cmdID := int(atomic.AddInt64(&cdpCmdCounter, 1))
|
||
|
|
msg := map[string]interface{}{
|
||
|
|
"id": cmdID,
|
||
|
|
"method": method,
|
||
|
|
"params": params,
|
||
|
|
}
|
||
|
|
b, _ := json.Marshal(msg)
|
||
|
|
if err := sendWSFrame(conn, b); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
for {
|
||
|
|
frame, err := readWSFrame(conn, reader)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var res map[string]interface{}
|
||
|
|
if err := json.Unmarshal(frame, &res); err == nil {
|
||
|
|
if idVal, ok := res["id"].(float64); ok && int(idVal) == cmdID {
|
||
|
|
return res, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type BrowserBridge struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
cmd *exec.Cmd
|
||
|
|
xvfbCmd *exec.Cmd
|
||
|
|
tmpDir string
|
||
|
|
port string
|
||
|
|
conn net.Conn
|
||
|
|
reader *bufio.Reader
|
||
|
|
browserBin string
|
||
|
|
userAgent string
|
||
|
|
headless bool
|
||
|
|
useXvfb bool
|
||
|
|
socksProxy string
|
||
|
|
targetURL string
|
||
|
|
availableModels []string
|
||
|
|
selectedModel string
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewBrowserBridge(browserBin, userAgent string, headless, useXvfb bool, socksProxy, targetURL string) *BrowserBridge {
|
||
|
|
if userAgent == "" {
|
||
|
|
userAgent = DefaultUserAgent
|
||
|
|
}
|
||
|
|
if targetURL == "" {
|
||
|
|
targetURL = DefaultTargetURL
|
||
|
|
}
|
||
|
|
return &BrowserBridge{
|
||
|
|
browserBin: browserBin,
|
||
|
|
userAgent: userAgent,
|
||
|
|
headless: headless,
|
||
|
|
useXvfb: useXvfb,
|
||
|
|
socksProxy: socksProxy,
|
||
|
|
targetURL: targetURL,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) Start() error {
|
||
|
|
bb.mu.Lock()
|
||
|
|
defer bb.mu.Unlock()
|
||
|
|
|
||
|
|
if bb.conn != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
binPath := findChromiumBinary(bb.browserBin)
|
||
|
|
if binPath == "" {
|
||
|
|
return fmt.Errorf("no chromium or google-chrome binary found on system")
|
||
|
|
}
|
||
|
|
|
||
|
|
var effectiveDisplay string
|
||
|
|
if bb.useXvfb {
|
||
|
|
xvfbPath, err := exec.LookPath("Xvfb")
|
||
|
|
if err != nil {
|
||
|
|
xvfbPath, err = exec.LookPath("Xfbdev")
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("warning: Xvfb not found on system, falling back to offscreen flags")
|
||
|
|
} else {
|
||
|
|
vDisplay := findFreeXDisplay()
|
||
|
|
xCmd := exec.Command(xvfbPath, vDisplay, "-screen", "0", "1280x800x24", "-ac", "-nolisten", "tcp")
|
||
|
|
if err := xCmd.Start(); err == nil {
|
||
|
|
bb.xvfbCmd = xCmd
|
||
|
|
effectiveDisplay = vDisplay
|
||
|
|
time.Sleep(300 * time.Millisecond)
|
||
|
|
log.Printf("Virtual X server started on display %s", vDisplay)
|
||
|
|
} else {
|
||
|
|
log.Printf("warning: failed to start virtual X server: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if effectiveDisplay == "" {
|
||
|
|
effectiveDisplay = os.Getenv("DISPLAY")
|
||
|
|
}
|
||
|
|
|
||
|
|
tmpDir, err := os.MkdirTemp("", "groqqer_bridge_*")
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to create temp profile dir: %w", err)
|
||
|
|
}
|
||
|
|
bb.tmpDir = tmpDir
|
||
|
|
|
||
|
|
port, err := getFreePort()
|
||
|
|
if err != nil {
|
||
|
|
port = "9559"
|
||
|
|
}
|
||
|
|
bb.port = port
|
||
|
|
|
||
|
|
args := []string{
|
||
|
|
"--remote-debugging-port=" + port,
|
||
|
|
"--user-data-dir=" + tmpDir,
|
||
|
|
"--disable-gpu",
|
||
|
|
"--no-sandbox",
|
||
|
|
"--no-first-run",
|
||
|
|
"--no-default-browser-check",
|
||
|
|
"--disable-extensions",
|
||
|
|
"--disable-default-apps",
|
||
|
|
"--disable-background-networking",
|
||
|
|
"--disable-sync",
|
||
|
|
"--disable-translate",
|
||
|
|
"--mute-audio",
|
||
|
|
"--hide-scrollbars",
|
||
|
|
"--window-size=1280,800",
|
||
|
|
"--user-agent=" + bb.userAgent,
|
||
|
|
}
|
||
|
|
|
||
|
|
if bb.xvfbCmd != nil {
|
||
|
|
args = append(args, "--window-position=0,0")
|
||
|
|
} else {
|
||
|
|
args = append(args, "--window-position=-3000,-3000")
|
||
|
|
}
|
||
|
|
|
||
|
|
if bb.headless && bb.xvfbCmd == nil {
|
||
|
|
args = append(args, "--headless=new")
|
||
|
|
}
|
||
|
|
|
||
|
|
if bb.socksProxy != "" {
|
||
|
|
proxyArg := bb.socksProxy
|
||
|
|
if !strings.HasPrefix(proxyArg, "socks5://") && !strings.HasPrefix(proxyArg, "socks5h://") {
|
||
|
|
proxyArg = "socks5://" + proxyArg
|
||
|
|
}
|
||
|
|
args = append(args, "--proxy-server="+proxyArg)
|
||
|
|
}
|
||
|
|
|
||
|
|
args = append(args, bb.targetURL)
|
||
|
|
|
||
|
|
cmd := exec.Command(binPath, args...)
|
||
|
|
if effectiveDisplay != "" {
|
||
|
|
cmd.Env = append(os.Environ(), "DISPLAY="+effectiveDisplay)
|
||
|
|
} else {
|
||
|
|
cmd.Env = os.Environ()
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := cmd.Start(); err != nil {
|
||
|
|
return fmt.Errorf("failed to launch chromium: %w", err)
|
||
|
|
}
|
||
|
|
bb.cmd = cmd
|
||
|
|
|
||
|
|
// Connect to CDP target
|
||
|
|
var pageWS string
|
||
|
|
for attempt := 0; attempt < 25; attempt++ {
|
||
|
|
time.Sleep(500 * time.Millisecond)
|
||
|
|
resp, err := http.Get("http://127.0.0.1:" + port + "/json/list")
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var targets []map[string]interface{}
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&targets); err == nil {
|
||
|
|
resp.Body.Close()
|
||
|
|
for _, t := range targets {
|
||
|
|
if t["type"] == "page" {
|
||
|
|
if ws, ok := t["webSocketDebuggerUrl"].(string); ok && ws != "" {
|
||
|
|
pageWS = ws
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if pageWS != "" {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
resp.Body.Close()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if pageWS == "" {
|
||
|
|
return fmt.Errorf("timed out waiting for chromium CDP page target")
|
||
|
|
}
|
||
|
|
|
||
|
|
conn, reader, err := dialCDPWebSocket(pageWS)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to dial CDP websocket: %w", err)
|
||
|
|
}
|
||
|
|
bb.conn = conn
|
||
|
|
bb.reader = reader
|
||
|
|
|
||
|
|
log.Printf("Browser bridge connected to %s", bb.targetURL)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) Evaluate(js string) (interface{}, error) {
|
||
|
|
res, err := sendCDPCommand(bb.conn, bb.reader, "Runtime.evaluate", map[string]interface{}{
|
||
|
|
"expression": js,
|
||
|
|
"returnByValue": true,
|
||
|
|
"awaitPromise": true,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if r, ok := res["result"].(map[string]interface{}); ok {
|
||
|
|
if r2, ok := r["result"].(map[string]interface{}); ok {
|
||
|
|
return r2["value"], nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) WaitForReady(timeout time.Duration) error {
|
||
|
|
deadline := time.Now().Add(timeout)
|
||
|
|
for time.Now().Before(deadline) {
|
||
|
|
val, err := bb.Evaluate(`(() => {
|
||
|
|
const sb = document.querySelector('[data-testid="stSelectbox"]');
|
||
|
|
const ta = document.querySelector('textarea');
|
||
|
|
return sb && ta && !ta.disabled ? true : false;
|
||
|
|
})()`)
|
||
|
|
if err == nil && val == true {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
time.Sleep(300 * time.Millisecond)
|
||
|
|
}
|
||
|
|
return fmt.Errorf("timed out waiting for Streamlit chat interface to render")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) DiscoverModels() ([]string, error) {
|
||
|
|
if err := bb.WaitForReady(20 * time.Second); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
res, err := bb.Evaluate(`(() => {
|
||
|
|
const listbox = document.querySelector('[role="listbox"]');
|
||
|
|
if (!listbox) {
|
||
|
|
const btn = document.querySelector('[data-testid="stSelectbox"] button[aria-label="Open"]');
|
||
|
|
if (btn) btn.click();
|
||
|
|
}
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const start = Date.now();
|
||
|
|
const interval = setInterval(() => {
|
||
|
|
const options = Array.from(document.querySelectorAll('[role="option"]')).map(el => el.innerText.trim()).filter(Boolean);
|
||
|
|
if (options.length > 0 || Date.now() - start > 3000) {
|
||
|
|
clearInterval(interval);
|
||
|
|
const btn = document.querySelector('[data-testid="stSelectbox"] button[aria-label="Open"]') || document.body;
|
||
|
|
btn.click();
|
||
|
|
resolve(options);
|
||
|
|
}
|
||
|
|
}, 50);
|
||
|
|
});
|
||
|
|
})()`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
var rawList []string
|
||
|
|
if arr, ok := res.([]interface{}); ok {
|
||
|
|
for _, item := range arr {
|
||
|
|
if str, ok := item.(string); ok && str != "" {
|
||
|
|
rawList = append(rawList, str)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var modelIDs []string
|
||
|
|
for _, item := range rawList {
|
||
|
|
cleanID := item
|
||
|
|
if idx := strings.Index(item, " ("); idx != -1 {
|
||
|
|
cleanID = strings.TrimSpace(item[:idx])
|
||
|
|
}
|
||
|
|
if cleanID != "" {
|
||
|
|
modelIDs = append(modelIDs, cleanID)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
curr, _ := bb.Evaluate(`(() => {
|
||
|
|
const input = document.querySelector('[data-testid="stSelectbox"] input');
|
||
|
|
return input ? input.value : '';
|
||
|
|
})()`)
|
||
|
|
if currStr, ok := curr.(string); ok && currStr != "" {
|
||
|
|
cleanCurr := currStr
|
||
|
|
if idx := strings.Index(currStr, " ("); idx != -1 {
|
||
|
|
cleanCurr = strings.TrimSpace(currStr[:idx])
|
||
|
|
}
|
||
|
|
bb.selectedModel = cleanCurr
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(modelIDs) > 0 {
|
||
|
|
bb.availableModels = modelIDs
|
||
|
|
}
|
||
|
|
|
||
|
|
return modelIDs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) SwitchModel(modelID string) error {
|
||
|
|
if modelID == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
targetSubstr := strings.ToLower(strings.TrimSpace(modelID))
|
||
|
|
if strings.Contains(targetSubstr, "/") {
|
||
|
|
parts := strings.Split(targetSubstr, "/")
|
||
|
|
targetSubstr = parts[len(parts)-1]
|
||
|
|
}
|
||
|
|
|
||
|
|
targetJSON, _ := json.Marshal(targetSubstr)
|
||
|
|
js := fmt.Sprintf(`(() => {
|
||
|
|
const target = %s;
|
||
|
|
const input = document.querySelector('[data-testid="stSelectbox"] input');
|
||
|
|
if (input && input.value.toLowerCase().includes(target)) {
|
||
|
|
return Promise.resolve({ success: true, model: input.value });
|
||
|
|
}
|
||
|
|
|
||
|
|
const listbox = document.querySelector('[role="listbox"]');
|
||
|
|
if (!listbox) {
|
||
|
|
const btn = document.querySelector('[data-testid="stSelectbox"] button[aria-label="Open"]') || input;
|
||
|
|
if (btn) btn.click();
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const start = Date.now();
|
||
|
|
const interval = setInterval(() => {
|
||
|
|
const options = Array.from(document.querySelectorAll('[role="option"]'));
|
||
|
|
if (options.length > 0) {
|
||
|
|
const matched = options.find(o => o.innerText.toLowerCase().includes(target));
|
||
|
|
if (matched) {
|
||
|
|
clearInterval(interval);
|
||
|
|
const chosenText = matched.innerText;
|
||
|
|
matched.click();
|
||
|
|
resolve({ success: true, model: chosenText });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Date.now() - start > 3000) {
|
||
|
|
clearInterval(interval);
|
||
|
|
const btn = document.querySelector('[data-testid="stSelectbox"] button[aria-label="Open"]') || document.body;
|
||
|
|
btn.click();
|
||
|
|
resolve({ success: false, model: '', error: 'Option not found' });
|
||
|
|
}
|
||
|
|
}, 50);
|
||
|
|
});
|
||
|
|
})()`, string(targetJSON))
|
||
|
|
|
||
|
|
res, err := bb.Evaluate(js)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if rMap, ok := res.(map[string]interface{}); ok {
|
||
|
|
if success, _ := rMap["success"].(bool); success {
|
||
|
|
chosen, _ := rMap["model"].(string)
|
||
|
|
cleanCurr := chosen
|
||
|
|
if idx := strings.Index(chosen, " ("); idx != -1 {
|
||
|
|
cleanCurr = strings.TrimSpace(chosen[:idx])
|
||
|
|
}
|
||
|
|
bb.selectedModel = cleanCurr
|
||
|
|
log.Printf("Successfully switched model to: %s", cleanCurr)
|
||
|
|
time.Sleep(1 * time.Second)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
log.Printf("Model %s not found in selectbox, keeping current model %s", modelID, bb.selectedModel)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) ResetChat() error {
|
||
|
|
res, err := bb.Evaluate(`(() => {
|
||
|
|
const msgs = document.querySelectorAll('[data-testid="stChatMessage"]');
|
||
|
|
const retryBtn = Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Continue without retrying'));
|
||
|
|
const clearBtn = Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Clear Chat'));
|
||
|
|
const ta = document.querySelector('textarea');
|
||
|
|
|
||
|
|
if (msgs.length === 0 && !retryBtn && ta && !ta.disabled) {
|
||
|
|
return 'clean';
|
||
|
|
}
|
||
|
|
|
||
|
|
if (clearBtn) {
|
||
|
|
clearBtn.click();
|
||
|
|
return 'cleared';
|
||
|
|
}
|
||
|
|
if (retryBtn) {
|
||
|
|
retryBtn.click();
|
||
|
|
return 'retried';
|
||
|
|
}
|
||
|
|
return 'none';
|
||
|
|
})()`)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if res == "clean" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
deadline := time.Now().Add(6 * time.Second)
|
||
|
|
for time.Now().Before(deadline) {
|
||
|
|
time.Sleep(100 * time.Millisecond)
|
||
|
|
ready, _ := bb.Evaluate(`(() => {
|
||
|
|
const msgs = document.querySelectorAll('[data-testid="stChatMessage"]');
|
||
|
|
const ta = document.querySelector('textarea[data-testid="stChatInputTextArea"]') || document.querySelector('textarea');
|
||
|
|
const retryBtn = Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Continue without retrying'));
|
||
|
|
if (retryBtn) {
|
||
|
|
retryBtn.click();
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return msgs.length === 0 && ta && !ta.disabled;
|
||
|
|
})()`)
|
||
|
|
if ready == true {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) SubmitPrompt(promptText string) error {
|
||
|
|
promptJSON, err := json.Marshal(promptText)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure textarea is present and enabled
|
||
|
|
deadline := time.Now().Add(10 * time.Second)
|
||
|
|
for time.Now().Before(deadline) {
|
||
|
|
val, _ := bb.Evaluate(`(() => {
|
||
|
|
const ta = document.querySelector('textarea');
|
||
|
|
return ta && !ta.disabled;
|
||
|
|
})()`)
|
||
|
|
if val == true {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
time.Sleep(100 * time.Millisecond)
|
||
|
|
}
|
||
|
|
|
||
|
|
js := fmt.Sprintf(`(() => {
|
||
|
|
const ta = document.querySelector('textarea[data-testid="stChatInputTextArea"]') || document.querySelector('textarea');
|
||
|
|
if (!ta) return 'no textarea';
|
||
|
|
ta.focus();
|
||
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
||
|
|
nativeSetter.call(ta, %s);
|
||
|
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
|
|
ta.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
|
|
||
|
|
const btn = document.querySelector('button[data-testid="stChatInputSubmitButton"]');
|
||
|
|
if (btn) {
|
||
|
|
btn.removeAttribute('disabled');
|
||
|
|
btn.disabled = false;
|
||
|
|
btn.click();
|
||
|
|
return 'clicked';
|
||
|
|
}
|
||
|
|
|
||
|
|
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
||
|
|
return 'enter';
|
||
|
|
})()`, string(promptJSON))
|
||
|
|
|
||
|
|
_, err = bb.Evaluate(js)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify that new message is accepted by checking if count > 0
|
||
|
|
confirmDeadline := time.Now().Add(4 * time.Second)
|
||
|
|
for time.Now().Before(confirmDeadline) {
|
||
|
|
currCountVal, _ := bb.Evaluate(`document.querySelectorAll('[data-testid="stChatMessage"]').length`)
|
||
|
|
if c, ok := currCountVal.(float64); ok && int(c) > 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
time.Sleep(100 * time.Millisecond)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Retry clicking if not registered yet
|
||
|
|
bb.Evaluate(`(() => {
|
||
|
|
const btn = document.querySelector('button[data-testid="stChatInputSubmitButton"]');
|
||
|
|
if (btn) btn.click();
|
||
|
|
const ta = document.querySelector('textarea');
|
||
|
|
if (ta) ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
||
|
|
})()`)
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (bb *BrowserBridge) Close() {
|
||
|
|
bb.mu.Lock()
|
||
|
|
defer bb.mu.Unlock()
|
||
|
|
|
||
|
|
if bb.conn != nil {
|
||
|
|
bb.conn.Close()
|
||
|
|
bb.conn = nil
|
||
|
|
}
|
||
|
|
if bb.cmd != nil && bb.cmd.Process != nil {
|
||
|
|
_ = bb.cmd.Process.Kill()
|
||
|
|
_ = bb.cmd.Wait()
|
||
|
|
bb.cmd = nil
|
||
|
|
}
|
||
|
|
if bb.xvfbCmd != nil && bb.xvfbCmd.Process != nil {
|
||
|
|
_ = bb.xvfbCmd.Process.Kill()
|
||
|
|
_ = bb.xvfbCmd.Wait()
|
||
|
|
bb.xvfbCmd = nil
|
||
|
|
}
|
||
|
|
if bb.tmpDir != "" {
|
||
|
|
_ = os.RemoveAll(bb.tmpDir)
|
||
|
|
bb.tmpDir = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Gateway Controller & Request Orchestration
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
type GroqqerGateway struct {
|
||
|
|
bridge *BrowserBridge
|
||
|
|
models []ModelItem
|
||
|
|
modelsMu sync.RWMutex
|
||
|
|
defaultModel string
|
||
|
|
port int
|
||
|
|
}
|
||
|
|
|
||
|
|
func DefaultFallbackModels() []string {
|
||
|
|
return []string{
|
||
|
|
"openai/gpt-oss-20b",
|
||
|
|
"openai/gpt-oss-120b",
|
||
|
|
"qwen/qwen3.6-27b",
|
||
|
|
"qwen/qwen3.8-27b",
|
||
|
|
"groq/compound",
|
||
|
|
"groq/compound-mini",
|
||
|
|
"allam-2-7b",
|
||
|
|
"llama-3.3-70b-versatile",
|
||
|
|
"llama-3.1-8b-instant",
|
||
|
|
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *GroqqerGateway) RefreshModels() {
|
||
|
|
discovered, err := g.bridge.DiscoverModels()
|
||
|
|
if err != nil || len(discovered) == 0 {
|
||
|
|
discovered = DefaultFallbackModels()
|
||
|
|
}
|
||
|
|
|
||
|
|
g.modelsMu.Lock()
|
||
|
|
defer g.modelsMu.Unlock()
|
||
|
|
|
||
|
|
now := time.Now().Unix()
|
||
|
|
var items []ModelItem
|
||
|
|
for _, id := range discovered {
|
||
|
|
items = append(items, ModelItem{
|
||
|
|
ID: id,
|
||
|
|
Object: "model",
|
||
|
|
Created: now,
|
||
|
|
OwnedBy: "groq",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
g.models = items
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *GroqqerGateway) GetModels() []ModelItem {
|
||
|
|
g.modelsMu.RLock()
|
||
|
|
defer g.modelsMu.RUnlock()
|
||
|
|
if len(g.models) == 0 {
|
||
|
|
now := time.Now().Unix()
|
||
|
|
var items []ModelItem
|
||
|
|
for _, id := range DefaultFallbackModels() {
|
||
|
|
items = append(items, ModelItem{
|
||
|
|
ID: id,
|
||
|
|
Object: "model",
|
||
|
|
Created: now,
|
||
|
|
OwnedBy: "groq",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return items
|
||
|
|
}
|
||
|
|
out := make([]ModelItem, len(g.models))
|
||
|
|
copy(out, g.models)
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *GroqqerGateway) HandleModels(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||
|
|
if r.Method == "OPTIONS" {
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
models := g.GetModels()
|
||
|
|
resp := ModelsResponse{
|
||
|
|
Object: "list",
|
||
|
|
Data: models,
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
json.NewEncoder(w).Encode(resp)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *GroqqerGateway) HandleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||
|
|
if r.Method == "OPTIONS" {
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if r.Method != "POST" {
|
||
|
|
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", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(req.Messages) == 0 {
|
||
|
|
http.Error(w, "messages array must not be empty", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
requestedModel := req.Model
|
||
|
|
if requestedModel == "" {
|
||
|
|
requestedModel = g.defaultModel
|
||
|
|
}
|
||
|
|
|
||
|
|
promptText := FormatPrompt(req)
|
||
|
|
completionID := "chatcmpl-" + GenerateUUID()
|
||
|
|
created := time.Now().Unix()
|
||
|
|
|
||
|
|
// Lock bridge for exclusive session execution
|
||
|
|
g.bridge.mu.Lock()
|
||
|
|
defer g.bridge.mu.Unlock()
|
||
|
|
|
||
|
|
log.Printf("Handling completion: model=%s, stream=%v, messages=%d", requestedModel, req.Stream, len(req.Messages))
|
||
|
|
|
||
|
|
// 1. Switch model if needed
|
||
|
|
if err := g.bridge.SwitchModel(requestedModel); err != nil {
|
||
|
|
log.Printf("model switch warning: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Ensure chat session is clean before submitting
|
||
|
|
if err := g.bridge.ResetChat(); err != nil {
|
||
|
|
log.Printf("reset chat warning: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Submit the prompt
|
||
|
|
if err := g.bridge.SubmitPrompt(promptText); err != nil {
|
||
|
|
log.Printf("submit prompt error: %v", err)
|
||
|
|
http.Error(w, "Failed to submit prompt to target space: "+err.Error(), http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
effectiveModel := g.bridge.selectedModel
|
||
|
|
if effectiveModel == "" {
|
||
|
|
effectiveModel = requestedModel
|
||
|
|
}
|
||
|
|
|
||
|
|
targetMinCount := 2
|
||
|
|
|
||
|
|
// 4. Handle response: streaming or non-streaming
|
||
|
|
if req.Stream {
|
||
|
|
flusher, ok := w.(http.Flusher)
|
||
|
|
if !ok {
|
||
|
|
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
streamer := NewStreamer(w, flusher, completionID, created, effectiveModel)
|
||
|
|
streamer.Role()
|
||
|
|
|
||
|
|
thinkingFilter := NewStreamThinkingFilter()
|
||
|
|
toolFilter := NewStreamToolCallFilter()
|
||
|
|
|
||
|
|
lastLen := 0
|
||
|
|
start := time.Now()
|
||
|
|
finishReason := "stop"
|
||
|
|
var fullCaptured strings.Builder
|
||
|
|
|
||
|
|
evalJS := fmt.Sprintf(`(() => {
|
||
|
|
const msgs = Array.from(document.querySelectorAll('[data-testid="stChatMessage"]'));
|
||
|
|
if (msgs.length < %d) {
|
||
|
|
const alertEl = document.querySelector('[data-testid="stAlert"], [data-testid="stNotification"]');
|
||
|
|
if (alertEl) {
|
||
|
|
return { text: '', done: true, error: alertEl.innerText };
|
||
|
|
}
|
||
|
|
return { text: '', done: false, error: '' };
|
||
|
|
}
|
||
|
|
|
||
|
|
const lastMsg = msgs[msgs.length - 1];
|
||
|
|
const md = lastMsg.querySelector('[data-testid="stMarkdownContainer"]');
|
||
|
|
const text = md ? md.innerText : '';
|
||
|
|
|
||
|
|
const ta = document.querySelector('textarea');
|
||
|
|
const hasModelCaption = lastMsg.innerText.includes('Model:') || !!lastMsg.querySelector('[data-testid="stCaptionContainer"]');
|
||
|
|
const alertEl = lastMsg.querySelector('[data-testid="stAlert"], [data-testid="stNotification"]');
|
||
|
|
const errText = alertEl ? alertEl.innerText : '';
|
||
|
|
|
||
|
|
const isDone = ((!ta || !ta.disabled) && hasModelCaption) || (errText !== '');
|
||
|
|
|
||
|
|
return { text, done: isDone, error: errText };
|
||
|
|
})()`, targetMinCount)
|
||
|
|
|
||
|
|
for time.Since(start) < 120*time.Second {
|
||
|
|
time.Sleep(35 * time.Millisecond)
|
||
|
|
|
||
|
|
res, err := g.bridge.Evaluate(evalJS)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if rMap, ok := res.(map[string]interface{}); ok {
|
||
|
|
txt, _ := rMap["text"].(string)
|
||
|
|
done, _ := rMap["done"].(bool)
|
||
|
|
errTxt, _ := rMap["error"].(string)
|
||
|
|
|
||
|
|
if errTxt != "" && strings.TrimSpace(txt) == "" {
|
||
|
|
txt = errTxt
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(txt) > lastLen {
|
||
|
|
delta := txt[lastLen:]
|
||
|
|
lastLen = len(txt)
|
||
|
|
fullCaptured.WriteString(delta)
|
||
|
|
|
||
|
|
thinkingFilter.Feed(delta,
|
||
|
|
func(cleanContent string) {
|
||
|
|
toolFilter.Feed(cleanContent,
|
||
|
|
func(userText string) {
|
||
|
|
streamer.Content(userText)
|
||
|
|
},
|
||
|
|
func(tc ToolCall) {
|
||
|
|
streamer.ToolCall(tc)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
},
|
||
|
|
func(reasoning string) {
|
||
|
|
streamer.Reasoning(reasoning)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if done && (len(txt) > 0 || errTxt != "") {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
thinkingFilter.Flush(
|
||
|
|
func(cleanContent string) {
|
||
|
|
toolFilter.Feed(cleanContent,
|
||
|
|
func(userText string) {
|
||
|
|
streamer.Content(userText)
|
||
|
|
},
|
||
|
|
func(tc ToolCall) {
|
||
|
|
streamer.ToolCall(tc)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
},
|
||
|
|
func(reasoning string) {
|
||
|
|
streamer.Reasoning(reasoning)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
toolFilter.Flush(
|
||
|
|
func(userText string) {
|
||
|
|
streamer.Content(userText)
|
||
|
|
},
|
||
|
|
func(tc ToolCall) {
|
||
|
|
streamer.ToolCall(tc)
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
if toolFilter.emittedCall {
|
||
|
|
finishReason = "tool_calls"
|
||
|
|
}
|
||
|
|
|
||
|
|
streamer.Finish(finishReason)
|
||
|
|
log.Printf("Streaming completion finished for %s in %v", effectiveModel, time.Since(start))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Non-streaming response
|
||
|
|
start := time.Now()
|
||
|
|
var fullText string
|
||
|
|
|
||
|
|
evalJS := fmt.Sprintf(`(() => {
|
||
|
|
const msgs = Array.from(document.querySelectorAll('[data-testid="stChatMessage"]'));
|
||
|
|
if (msgs.length < %d) {
|
||
|
|
const alertEl = document.querySelector('[data-testid="stAlert"], [data-testid="stNotification"]');
|
||
|
|
if (alertEl) {
|
||
|
|
return { text: '', done: true, error: alertEl.innerText };
|
||
|
|
}
|
||
|
|
return { text: '', done: false, error: '' };
|
||
|
|
}
|
||
|
|
|
||
|
|
const lastMsg = msgs[msgs.length - 1];
|
||
|
|
const md = lastMsg.querySelector('[data-testid="stMarkdownContainer"]');
|
||
|
|
const text = md ? md.innerText : '';
|
||
|
|
|
||
|
|
const ta = document.querySelector('textarea');
|
||
|
|
const hasModelCaption = lastMsg.innerText.includes('Model:') || !!lastMsg.querySelector('[data-testid="stCaptionContainer"]');
|
||
|
|
const alertEl = lastMsg.querySelector('[data-testid="stAlert"], [data-testid="stNotification"]');
|
||
|
|
const errText = alertEl ? alertEl.innerText : '';
|
||
|
|
|
||
|
|
const isDone = ((!ta || !ta.disabled) && hasModelCaption) || (errText !== '');
|
||
|
|
|
||
|
|
return { text, done: isDone, error: errText };
|
||
|
|
})()`, targetMinCount)
|
||
|
|
|
||
|
|
for time.Since(start) < 120*time.Second {
|
||
|
|
time.Sleep(50 * time.Millisecond)
|
||
|
|
|
||
|
|
res, err := g.bridge.Evaluate(evalJS)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if rMap, ok := res.(map[string]interface{}); ok {
|
||
|
|
txt, _ := rMap["text"].(string)
|
||
|
|
done, _ := rMap["done"].(bool)
|
||
|
|
errTxt, _ := rMap["error"].(string)
|
||
|
|
|
||
|
|
if errTxt != "" && strings.TrimSpace(txt) == "" {
|
||
|
|
txt = errTxt
|
||
|
|
}
|
||
|
|
|
||
|
|
fullText = txt
|
||
|
|
if done && (len(txt) > 0 || errTxt != "") {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
cleanContent, reasoningContent := ExtractThinking(fullText)
|
||
|
|
toolCalls, remainingContent, hasTools := DetectToolCalls(cleanContent)
|
||
|
|
|
||
|
|
finishReason := "stop"
|
||
|
|
if hasTools {
|
||
|
|
finishReason = "tool_calls"
|
||
|
|
cleanContent = remainingContent
|
||
|
|
}
|
||
|
|
|
||
|
|
WriteCompletionResponse(w, completionID, created, effectiveModel, cleanContent, reasoningContent, toolCalls, finishReason)
|
||
|
|
log.Printf("Completion finished for %s in %v", effectiveModel, time.Since(start))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Main Entrypoint & Signal Handling
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
port := flag.Int("port", 8080, "HTTP server listening port")
|
||
|
|
targetURL := flag.String("target", DefaultTargetURL, "Groq Streamlit space URL")
|
||
|
|
defaultModel := flag.String("default-model", DefaultModel, "Default model ID if unspecified")
|
||
|
|
browserBin := flag.String("browser", "", "Path to Chromium/Chrome binary")
|
||
|
|
userAgent := flag.String("user-agent", DefaultUserAgent, "User-Agent string")
|
||
|
|
flag.StringVar(userAgent, "ua", DefaultUserAgent, "User-Agent string (alias)")
|
||
|
|
|
||
|
|
useXvfb := flag.Bool("xvfb", true, "Manage virtual X server (Xvfb) for display isolation")
|
||
|
|
noXvfb := flag.Bool("no-xvfb", false, "Disable virtual X server")
|
||
|
|
headless := flag.Bool("headless", true, "Run Chromium in headless mode")
|
||
|
|
|
||
|
|
socksProxy := flag.String("socks", "", "SOCKS5 proxy (e.g. socks5://127.0.0.1:1080)")
|
||
|
|
flag.StringVar(socksProxy, "proxy", "", "Proxy address alias")
|
||
|
|
flag.StringVar(socksProxy, "socks5", "", "SOCKS5 proxy alias")
|
||
|
|
|
||
|
|
flag.Parse()
|
||
|
|
|
||
|
|
effectiveXvfb := *useXvfb
|
||
|
|
if *noXvfb {
|
||
|
|
effectiveXvfb = false
|
||
|
|
}
|
||
|
|
|
||
|
|
effectiveProxy := *socksProxy
|
||
|
|
if effectiveProxy == "" {
|
||
|
|
for _, envVar := range []string{"ALL_PROXY", "all_proxy", "SOCKS5_PROXY", "socks5_proxy", "SOCKS_PROXY", "socks_proxy"} {
|
||
|
|
if val := os.Getenv(envVar); val != "" {
|
||
|
|
effectiveProxy = val
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
bridge := NewBrowserBridge(*browserBin, *userAgent, *headless, effectiveXvfb, effectiveProxy, *targetURL)
|
||
|
|
|
||
|
|
log.Println("Starting groqqer browser bridge...")
|
||
|
|
if err := bridge.Start(); err != nil {
|
||
|
|
log.Fatalf("failed to start browser bridge: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
gateway := &GroqqerGateway{
|
||
|
|
bridge: bridge,
|
||
|
|
defaultModel: *defaultModel,
|
||
|
|
port: *port,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Initial discovery of live models
|
||
|
|
go func() {
|
||
|
|
log.Println("Discovering available models from Streamlit UI...")
|
||
|
|
gateway.RefreshModels()
|
||
|
|
models := gateway.GetModels()
|
||
|
|
log.Printf("Ready with %d active models. Default: %s", len(models), gateway.defaultModel)
|
||
|
|
}()
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/v1/models", gateway.HandleModels)
|
||
|
|
mux.HandleFunc("/models", gateway.HandleModels)
|
||
|
|
mux.HandleFunc("/v1/chat/completions", gateway.HandleChatCompletions)
|
||
|
|
mux.HandleFunc("/chat/completions", gateway.HandleChatCompletions)
|
||
|
|
|
||
|
|
// Root status handler
|
||
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.URL.Path != "/" {
|
||
|
|
http.NotFound(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
|
|
"service": "groqqer",
|
||
|
|
"status": "running",
|
||
|
|
"target": *targetURL,
|
||
|
|
"default": *defaultModel,
|
||
|
|
"models": "/v1/models",
|
||
|
|
"endpoints": []string{"/v1/chat/completions", "/v1/models"},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
server := &http.Server{
|
||
|
|
Addr: fmt.Sprintf(":%d", *port),
|
||
|
|
Handler: mux,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Graceful shutdown handling
|
||
|
|
sigChan := make(chan os.Signal, 1)
|
||
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
<-sigChan
|
||
|
|
log.Println("Shutting down groqqer...")
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
_ = server.Shutdown(ctx)
|
||
|
|
bridge.Close()
|
||
|
|
os.Exit(0)
|
||
|
|
}()
|
||
|
|
|
||
|
|
log.Printf("groqqer gateway listening on http://127.0.0.1:%d", *port)
|
||
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
|
|
log.Fatalf("server error: %v", err)
|
||
|
|
}
|
||
|
|
}
|