commit 354db0afcb74a0340644e856016238c2d8c07186 Author: Luxferre Date: Tue Sep 8 15:12:35 2026 +0300 feat: initial groqqer OpenAI proxy gateway for Groq Streamlit space diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b02b5a3 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +all: groqqer + +groqqer: + go build -trimpath -ldflags="-s -w" -o bin/groqqer groqqer.go + +clean: + rm -rf bin/ + +.PHONY: all groqqer clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..7946724 --- /dev/null +++ b/README.md @@ -0,0 +1,319 @@ +# groqqer + +`groqqer` is a lightweight, zero-dependency Go OpenAI-compatible API gateway and proxy for the Groq Streamlit chatbot space (`https://dromerosm-groq-chatbot.hf.space`). + +It converts standard OpenAI chat completions and model requests into automated headless browser interactions, providing fast inference with Groq models without requiring an API key. + +## Features + +- **OpenAI Compatible API**: Exposes standard `/v1/models` and `/v1/chat/completions` endpoints. Drop-in replacement for OpenAI SDKs, Open-WebUI, LiteLLM, LibreChat, and LangChain. +- **Zero Third-Party Dependencies**: Written entirely in pure Go using only the standard library (`net/http`, `encoding/json`, `os/exec`, `crypto/rand`, etc.). Includes a built-in RFC 6455 WebSocket CDP client and RFC 1928 SOCKS5 proxy client. +- **System Prompt Support**: Translates developer/system messages into natural instruction framing. +- **Streaming & Non-Streaming**: Supports Server-Sent Events (`stream: true`) with real-time token delivery, as well as synchronous JSON responses. +- **Tool / Function Calling**: Fully supports OpenAI `tools`, `tool_choice`, and multi-turn execution (`role: "tool"`). Automatically injects schemas and parses `` responses into OpenAI `tool_calls` payloads with `finish_reason: "tool_calls"`. +- **Reasoning Content**: Automatically parses `` and `` tags from reasoning models (such as Qwen 3.6/3.8) and streams or populates `reasoning_content` in accordance with OpenAI O-series conventions. +- **Dynamic Model Discovery**: Scrapes active models directly from the Streamlit UI on startup and allows seamless model switching between requests (`qwen/qwen3.6-27b`, `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `groq/compound`, etc.). +- **Display Isolation via Xvfb**: Automatically discovers and spawns a virtual X display (`:100` - `:199`) to keep browser automation completely isolated from host desktop displays. Gracefully falls back to offscreen coordinates if Xvfb is not present. +- **SOCKS5 Proxy Tunneling**: Tunnel browser traffic through SOCKS5 proxies using the `-socks` flag or `ALL_PROXY` / `SOCKS5_PROXY` environment variables. +- **Clean Session Isolation**: Automatically clears Streamlit conversation state between requests to prevent cumulative token window exhaustion or stale error states. + +## Architecture + +``` ++---------------------------+ +| OpenAI Client / SDK / App | ++---------------------------+ + | HTTP POST /v1/chat/completions + v ++---------------------------+ +| groqqer | +| (Pure Go Standard Lib) | ++---------------------------+ + | RFC 6455 WebSocket CDP + v ++---------------------------+ +| Chromium / Chrome | +| (Display :100+ via Xvfb) | ++---------------------------+ + | HTTPS + v ++---------------------------+ +| Groq Streamlit Space | +| (dromerosm-groq-chatbot) | ++---------------------------+ +``` + +## Requirements + +- **Linux** (x86_64 or aarch64) +- **Go 1.20+** +- **Chromium** or **Google Chrome** installed (`/usr/bin/chromium`, `/usr/bin/google-chrome`, or in `$PATH`) +- *(Optional)* **Xvfb** (`xorg-server-xvfb`) for display isolation + +## Installation & Build + +Clone or navigate to the repository, then build the binary: + +```bash +make +``` + +The optimized binary will be placed at `bin/groqqer`. + +## Running the Server + +Start the gateway with default settings: + +```bash +./bin/groqqer -port 8080 +``` + +### CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-port` | `8080` | HTTP server listening port | +| `-target` | `https://dromerosm-groq-chatbot.hf.space` | Target Groq Streamlit space URL | +| `-default-model` | `llama-3.3-70b-versatile` | Fallback model if request does not specify one | +| `-browser` | `""` | Custom path to Chromium/Chrome binary | +| `-xvfb` | `true` | Enable virtual X server display isolation | +| `-no-xvfb` | `false` | Disable virtual X server (run offscreen) | +| `-socks` | `""` | SOCKS5 proxy URL (e.g. `socks5://127.0.0.1:1080`) | +| `-user-agent` | *(Chrome 133 UA)* | Custom browser User-Agent string | + +## API Usage Examples + +### 1. List Available Models + +```bash +curl -s http://127.0.0.1:8080/v1/models +``` + +**Response:** +```json +{ + "object": "list", + "data": [ + {"id": "qwen/qwen3.6-27b", "object": "model", "created": 1788869470, "owned_by": "groq"}, + {"id": "openai/gpt-oss-120b", "object": "model", "created": 1788869470, "owned_by": "groq"}, + {"id": "openai/gpt-oss-20b", "object": "model", "created": 1788869470, "owned_by": "groq"}, + {"id": "groq/compound", "object": "model", "created": 1788869470, "owned_by": "groq"} + ] +} +``` + +--- + +### 2. Chat Completion (Non-Streaming) + +```bash +curl -s -X POST http://127.0.0.1:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen/qwen3.6-27b", + "messages": [ + {"role": "system", "content": "You are a concise assistant. Reply in one sentence."}, + {"role": "user", "content": "What is the capital of France?"} + ] + }' +``` + +**Response:** +```json +{ + "id": "chatcmpl-9691bbf8-8623-4155-a40f-a00e6b6903ac", + "object": "chat.completion", + "created": 1788869478, + "model": "qwen/qwen3.6-27b", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The capital of France is Paris.", + "reasoning_content": "Analyze User Input: Question: 'What is the capital of France?'..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + } +} +``` + +--- + +### 3. Streaming Chat Completion (SSE) + +```bash +curl -N -s -X POST http://127.0.0.1:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen/qwen3.6-27b", + "messages": [ + {"role": "user", "content": "Count from 1 to 4 with commas."} + ], + "stream": true + }' +``` + +**Output:** +``` +data: {"id":"chatcmpl-341d6d05","object":"chat.completion.chunk","created":1788869487,"model":"qwen/qwen3.6-27b","choices":[{"index":0,"delta":{"role":"assistant"}}]} + +data: {"id":"chatcmpl-341d6d05","object":"chat.completion.chunk","created":1788869487,"model":"qwen/qwen3.6-27b","choices":[{"index":0,"delta":{"reasoning_content":"Thinking process..."}}]} + +data: {"id":"chatcmpl-341d6d05","object":"chat.completion.chunk","created":1788869487,"model":"qwen/qwen3.6-27b","choices":[{"index":0,"delta":{"content":"1, 2, 3,"}}]} + +data: {"id":"chatcmpl-341d6d05","object":"chat.completion.chunk","created":1788869487,"model":"qwen/qwen3.6-27b","choices":[{"index":0,"delta":{"content":" 4"}}]} + +data: {"id":"chatcmpl-341d6d05","object":"chat.completion.chunk","created":1788869487,"model":"qwen/qwen3.6-27b","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] +``` + +--- + +### 4. Tool / Function Calling + +```bash +curl -s -X POST http://127.0.0.1:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen/qwen3.6-27b", + "messages": [ + {"role": "user", "content": "What is the current weather in Tokyo?"} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get current weather for a city", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "The city and country, e.g. Tokyo, Japan"} + }, + "required": ["location"] + } + } + } + ], + "tool_choice": "auto" + }' +``` + +**Response:** +```json +{ + "id": "chatcmpl-cb050ba0-2573-403f-a1db-e76cdfb8f99d", + "object": "chat.completion", + "created": 1788869493, + "model": "qwen/qwen3.6-27b", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_ff437271", + "type": "function", + "function": { + "name": "get_current_weather", + "arguments": "{\"location\":\"Tokyo, Japan\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] +} +``` + +--- + +### 5. Multi-Turn Tool Response Execution + +Once you run your tool locally, send the tool result back using `role: "tool"`: + +```bash +curl -s -X POST http://127.0.0.1:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen/qwen3.6-27b", + "messages": [ + {"role": "user", "content": "What is the current weather in Tokyo?"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_ff437271", + "type": "function", + "function": { + "name": "get_current_weather", + "arguments": "{\"location\":\"Tokyo, Japan\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_ff437271", + "name": "get_current_weather", + "content": "{\"temperature\": \"18°C\", \"condition\": \"Sunny\", \"humidity\": \"45%\"}" + } + ] + }' +``` + +**Response:** +```json +{ + "id": "chatcmpl-aad3ed3e-b401-41b8-9b3b-a9455070def5", + "object": "chat.completion", + "created": 1788869501, + "model": "qwen/qwen3.6-27b", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "It is currently sunny in Tokyo with a temperature of 18°C and 45% humidity." + }, + "finish_reason": "stop" + } + ] +} +``` + +## Python OpenAI SDK Example + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://127.0.0.1:8080/v1", + api_key="not-needed" +) + +response = client.chat.completions.create( + model="qwen/qwen3.6-27b", + messages=[ + {"role": "system", "content": "You are a helpful coding assistant."}, + {"role": "user", "content": "Write a Python function to reverse a string."} + ] +) + +print(response.choices[0].message.content) +``` + +## License + +MIT License diff --git a/bin/groqqer b/bin/groqqer new file mode 100755 index 0000000..dd94681 Binary files /dev/null and b/bin/groqqer differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ec6420 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module groqqer + +go 1.26.5 diff --git a/groqqer.go b/groqqer.go new file mode 100644 index 0000000..dff8791 --- /dev/null +++ b/groqqer.go @@ -0,0 +1,2251 @@ +// 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\n") + sb.WriteString(string(toolsBytes)) + sb.WriteString("\n\n\nWhen you need to call a function, respond ONLY with a block formatted exactly as follows:\n\n{\"name\": \"\", \"arguments\": {}}\n\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("\n{\"name\": %q, \"arguments\": %s}\n", 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("\n{\"name\": %q, \"content\": %s}\n", 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, "") { + inner = strings.TrimPrefix(inner, "") + } + if strings.HasSuffix(inner, "") { + inner = strings.TrimSuffix(inner, "") + } + inner = cleanJSONBlock(inner) + + if tc, ok := parseSingleToolCall(inner); ok { + return tc, true + } + + var fnName string + if strings.Contains(inner, "") && strings.Contains(inner, "") { + nStart := strings.Index(inner, "") + len("") + nEnd := strings.Index(inner, "") + if nStart < nEnd { + fnName = strings.TrimSpace(inner[nStart:nEnd]) + } + } + + var argsStr string + if strings.Contains(inner, "") && strings.Contains(inner, "") { + aStart := strings.Index(inner, "") + len("") + aEnd := strings.Index(inner, "") + 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, "") { + sIdx := strings.Index(s, "") + rest := s[sIdx+len(""):] + + relNextSIdx := strings.Index(rest, "") + var nextSIdx int + if relNextSIdx != -1 { + nextSIdx = sIdx + len("") + relNextSIdx + } else { + nextSIdx = -1 + } + + relEIdx := strings.Index(rest, "") + var eIdx int + if relEIdx != -1 { + eIdx = sIdx + len("") + relEIdx + } else { + eIdx = -1 + } + + var blockText string + var blockEndPos int + + if eIdx != -1 && (nextSIdx == -1 || eIdx < nextSIdx) { + blockEndPos = eIdx + len("") + 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, "") { + st := strings.Index(remaining, "") + rest := remaining[st+len(""):] + + relNext := strings.Index(rest, "") + var nextSt int + if relNext != -1 { + nextSt = st + len("") + relNext + } else { + nextSt = -1 + } + + relEn := strings.Index(rest, "") + var en int + if relEn != -1 { + en = st + len("") + relEn + } else { + en = -1 + } + + if en != -1 && (nextSt == -1 || en < nextSt) { + remaining = strings.TrimSpace(remaining[:st] + remaining[en+len(""):]) + } 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, "") && strings.Contains(content, "") { + start := strings.Index(content, "") + end := strings.Index(content, "") + if start < end { + reasoning := content[start+len("") : end] + rem := content[:start] + content[end+len(""):] + 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 := "" + thinkEndTag := "" + + thinkStartPrefixes := []string{"<", " 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 := "" + toolEndTag := "" + + startPrefixes := []string{"<", " 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("" + f.toolCallBuf + ""); ok2 { + idxCopy := f.toolIndex + tc2.Index = &idxCopy + f.toolIndex++ + f.emittedCall = true + onToolCall(tc2) + } else { + onContent("" + f.toolCallBuf + "") + } + 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("" + f.toolCallBuf + ""); ok2 { + idxCopy := f.toolIndex + tc2.Index = &idxCopy + f.emittedCall = true + onToolCall(tc2) + } else { + onContent("" + 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) + } +}