diff --git a/Dockerfile b/Dockerfile
index 0d87ffa3d7f5111f5805f76ffc82de0f807545de..39fb218624ca2d5667cb69cc67e8d1e32a286b4a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,6 +2,14 @@ FROM akashyadav758/chrome:latest
USER root
+# Copy extensions into the container
+COPY chatgpt-free-api/gpt-extension /opt/gpt-extension
+COPY free-gemini-api/extension /opt/gemini-extension
+COPY flow-agent/extension /opt/flow-extension
+
+# Set correct permissions
+RUN chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension
+
# Replace the start script with the non-root version
COPY --chmod=755 start_hf.sh /start.sh
diff --git a/chatgpt-free-api/.gitignore b/chatgpt-free-api/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..19c58277dc65fa6e5df70015b799d6b1805d53ca
--- /dev/null
+++ b/chatgpt-free-api/.gitignore
@@ -0,0 +1,13 @@
+# Binaries
+agent
+agent.exe
+
+# Private Session Cookies
+cookies.json
+
+# Logs & Debug
+*.log
+debug_poll.json
+
+# Local Output Files
+output/
diff --git a/chatgpt-free-api/README.md b/chatgpt-free-api/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0360524f7c1faf95a192f3cf6a638e80b5ca13da
--- /dev/null
+++ b/chatgpt-free-api/README.md
@@ -0,0 +1,56 @@
+# ChatGPT Free API Agent
+
+A flat-structure Go server that connects with the Chrome extension (`gpt-extension`) to execute ChatGPT backend API requests directly from your authenticated browser session.
+
+## Setup & Running
+
+1. **Build & Run the Server:**
+ ```bash
+ go build -o agent .
+ ./agent
+ ```
+2. **Load Chrome Extension:**
+ - Open `chrome://extensions` in Chrome.
+ - Enable **Developer mode** (top-right).
+ - Click **Load unpacked** and select the `gpt-extension` folder (in the parent directory).
+3. **Login to ChatGPT:**
+ - Open `https://chatgpt.com/` and log in. The extension will automatically connect to the running Go agent (green status badge = connected).
+
+---
+
+## API Usage
+
+### 1. Send Chat Request
+```bash
+curl -X POST "http://127.0.0.1:9225/api/chat" \
+ -H "Content-Type: application/json" \
+ -d '{"prompt": "Why is sky blue?", "conversation_id": ""}'
+```
+
+### 2. Bulk Sequential Requests
+```bash
+curl -X POST "http://127.0.0.1:9225/api/chat/bulk" \
+ -H "Content-Type: application/json" \
+ -d '{"prompts": ["hello", "how are you"]}'
+```
+
+### 3. Edit Existing Message
+```bash
+curl -X POST "http://127.0.0.1:9225/api/chat/edit" \
+ -H "Content-Type: application/json" \
+ -d '{"prompt": "updated prompt", "conversation_id": "conv-id", "message_id": "msg-id", "parent_id": "parent-id"}'
+```
+
+### 4. Health & Cookie Status
+- **Health Check:** `GET http://127.0.0.1:9225/health`
+- **Cookie Status:** `GET http://127.0.0.1:9225/api/cookies/status`
+
+---
+
+## Testing
+
+Run the integration tests (requires agent running on port 9225):
+```bash
+./test.sh # Run all tests (text, thread, bulk, image gen/edit)
+./test_image_only.sh # Run only image generation/editing tests
+```
diff --git a/chatgpt-free-api/agent.go b/chatgpt-free-api/agent.go
new file mode 100644
index 0000000000000000000000000000000000000000..f703321bf2f1bf9bb859a1d715ac23fc86434b64
--- /dev/null
+++ b/chatgpt-free-api/agent.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+)
+
+var (
+ activeConvMu sync.Mutex
+ activeConvID string
+)
+
+func getActiveConversationID() string {
+ activeConvMu.Lock()
+ defer activeConvMu.Unlock()
+ return activeConvID
+}
+
+func setActiveConversationID(id string) {
+ activeConvMu.Lock()
+ activeConvID = id
+ activeConvMu.Unlock()
+}
+
+func clearActiveConversationID() {
+ activeConvMu.Lock()
+ activeConvID = ""
+ activeConvMu.Unlock()
+}
+
+func Start() {
+ loadConfig()
+ LoadCookiesFromFile()
+
+ promptFlag := flag.String("prompt", "", "Prompt to send to ChatGPT after the extension connects")
+ onceFlag := flag.Bool("once", false, "Send the prompt, print the response, then exit")
+ timeoutFlag := flag.Duration("timeout", cfg.Timeout(), "Maximum time to wait for extension/response")
+ flag.Parse()
+
+ http.HandleFunc("/", handleWS)
+ http.HandleFunc("/health", handleHealth)
+ http.HandleFunc("/api/ext/callback", handleCallback)
+ http.HandleFunc("/api/ext/reload", func(w http.ResponseWriter, r *http.Request) {
+ extMu.Lock()
+ conn := extConn
+ extMu.Unlock()
+ if conn == nil {
+ writeJSONStatus(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "extension not connected"})
+ return
+ }
+ err := conn.WriteJSON(WSMessage{
+ Method: "reload_extension",
+ })
+ if err != nil {
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "message": "reload request sent"})
+ })
+ http.HandleFunc("/api/conversation", func(w http.ResponseWriter, r *http.Request) {
+ convID := r.URL.Query().Get("conversation_id")
+ if convID == "" {
+ writeJSONStatus(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "conversation_id required"})
+ return
+ }
+ token, err := getSessionToken()
+ if err != nil {
+ writeJSONStatus(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ res, err := callChatGPTAPI(apiCallParams{
+ URL: "https://chatgpt.com/backend-api/conversation/" + convID,
+ Method: "GET",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ },
+ }, cfg.APITimeout())
+ if err != nil {
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(res.Status)
+ w.Write([]byte(res.Body))
+ })
+ http.HandleFunc("/api/chat", handleChat)
+ http.HandleFunc("/api/chat/bulk", handleChatBulk)
+ http.HandleFunc("/api/chat/edit", handleChatEdit)
+ http.HandleFunc("/api/chatgpt/test", handleChat)
+ http.HandleFunc("/api/sniffs", handleSniffs)
+ http.HandleFunc("/api/download", handleDownload)
+ http.HandleFunc("/v1/chat/completions", handleOpenAIChat)
+ http.HandleFunc("/api/cookies/status", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, map[string]any{
+ "ok": true,
+ "has_cookies": HasCookies(),
+ "last_sync": GetLastCookieSync().Format(time.RFC3339),
+ "cookie_header_len": len(GetCachedCookieHeader()),
+ "extension_connected": isExtensionConnected(),
+ })
+ })
+
+ if *promptFlag != "" {
+ go runPromptFromCLI(*promptFlag, *timeoutFlag, *onceFlag)
+ }
+
+ log.Printf("ChatGPT Agent listening on http://%s", cfg.ListenAddr)
+ log.Println("Reload the ChatGPT Browser Bridge extension if it is not connected.")
+ log.Println("Endpoints: GET/POST /api/chat, POST /api/chat/bulk, POST /api/chat/edit, GET /api/sniffs, GET /health")
+ if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func runPromptFromCLI(prompt string, timeout time.Duration, once bool) {
+ log.Println("Waiting for extension API bridge connection...")
+ if !waitForExtension(timeout) {
+ log.Println("Extension did not connect before timeout")
+ if once {
+ os.Exit(1)
+ }
+ return
+ }
+
+ log.Printf("Sending prompt: %s", prompt)
+ text, _, err := sendChat(prompt)
+ if err != nil {
+ log.Printf("ChatGPT request failed: %v", err)
+ if once {
+ os.Exit(1)
+ }
+ return
+ }
+
+ fmt.Println(text)
+ if once {
+ os.Exit(0)
+ }
+}
diff --git a/chatgpt-free-api/api_bridge.go b/chatgpt-free-api/api_bridge.go
new file mode 100644
index 0000000000000000000000000000000000000000..e7583df29f0104fd0b939c653ba9755a594a5e49
--- /dev/null
+++ b/chatgpt-free-api/api_bridge.go
@@ -0,0 +1,136 @@
+package main
+
+import (
+ "log"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+)
+
+var fileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
+
+func sendChat(prompt string) (string, string, error) {
+ return sendChatWithConversation(prompt, "", cfg.DefaultModel, cfg.DefaultThinkingEffort)
+}
+
+func sendChatWithConversation(prompt, conversationID, model, thinkingEffort string) (string, string, error) {
+ return sendChatWithConversationAndAttachments(prompt, conversationID, model, thinkingEffort, nil)
+}
+
+func sendChatWithConversationAndAttachments(prompt, conversationID, model, thinkingEffort string, attachments []FileAttachment) (string, string, error) {
+ var response, rawText, newConvID string
+ var err error
+
+ // ─── Try 1: Direct Go HTTP call using synced cookies (fastest, no extension needed) ───
+ if HasCookies() && len(attachments) == 0 {
+ log.Println("[chat] Attempting direct API call using synced cookies...")
+ response, rawText, newConvID, err = SendChatDirect(prompt, conversationID, model, thinkingEffort)
+ if err == nil {
+ log.Println("[chat] ✅ Direct API call succeeded!")
+ return processResponse(response, rawText, newConvID, prompt, attachments)
+ }
+ log.Printf("[chat] ⚠️ Direct API call failed: %v — falling back to extension", err)
+ }
+
+ // ─── Try 2: Extension full_conversation method (browser-based) ───
+ log.Println("[chat] Using extension full_conversation method...")
+ response, rawText, newConvID, err = sendChatViaFullConversation(prompt, conversationID, model, thinkingEffort, attachments...)
+ if err != nil {
+ return "", "", err
+ }
+
+ return processResponse(response, rawText, newConvID, prompt, attachments)
+}
+
+// processResponse handles post-processing: auto-download images, poll for async images
+func processResponse(response, rawText, newConvID, prompt string, attachments []FileAttachment) (string, string, error) {
+ // Auto-download any files found in the rawText or response
+ var rawFileIDs []string
+ rawFileIDs = append(rawFileIDs, fileIDRegexp.FindAllString(rawText, -1)...)
+ rawFileIDs = append(rawFileIDs, fileIDRegexp.FindAllString(response, -1)...)
+
+ // Dedup and filter file IDs (excluding any attachment IDs we uploaded)
+ uploadedIDs := make(map[string]bool)
+ for _, att := range attachments {
+ uploadedIDs[att.ID] = true
+ }
+ seen := make(map[string]bool)
+ var fileIDs []string
+ for _, id := range rawFileIDs {
+ if !seen[id] && !uploadedIDs[id] {
+ seen[id] = true
+ fileIDs = append(fileIDs, id)
+ }
+ }
+
+ log.Printf("[chat] Found %d new file ID(s) to download: %v", len(fileIDs), fileIDs)
+
+ if len(fileIDs) > 0 {
+ for _, id := range fileIDs {
+ registerFilePrompt(id, prompt)
+ name := getPromptFilename(prompt, id)
+ log.Printf("[auto-download] Detected image %s (%s) in response, downloading...", id, name)
+
+ // Retry download up to 10 times because the image might still be generating/saving on OpenAI backend
+ var data []byte
+ var err error
+ for attempt := 1; attempt <= 10; attempt++ {
+ data, err = downloadChatGPTFile(id)
+ if err == nil {
+ break
+ }
+ log.Printf("[auto-download] Image %s not ready yet, retrying in 2 seconds... (attempt %d/10)", id, attempt)
+ time.Sleep(2 * time.Second)
+ }
+
+ if err == nil {
+ _ = os.MkdirAll("output", 0755)
+ localPath := "output/" + name + ".png"
+ err = os.WriteFile(localPath, data, 0644)
+ if err != nil {
+ log.Printf("[auto-download] Error saving file %s: %v", localPath, err)
+ } else {
+ log.Printf("[auto-download] Successfully saved image to %s", localPath)
+ }
+ } else {
+ log.Printf("[auto-download] Error downloading image %s: %v", id, err)
+ }
+ }
+ }
+
+ shouldPoll := newConvID != "" && (strings.Contains(response, "Processing image") ||
+ strings.Contains(response, "creating images") ||
+ strings.Contains(response, "generating your image") ||
+ len(attachments) > 0 ||
+ isPromptForImage(prompt))
+
+ if shouldPoll {
+ log.Printf("[chat] detected async image generation/edit in conversation %s, starting poll...", newConvID)
+ var excludeIDs []string
+ for _, att := range attachments {
+ excludeIDs = append(excludeIDs, att.ID)
+ }
+ polledResponse, pollErr := pollForImage(prompt, newConvID, 150*time.Second, excludeIDs...)
+ if pollErr == nil {
+ return polledResponse, newConvID, nil
+ }
+ log.Printf("[chat] image poll failed: %v, returning original response", pollErr)
+ }
+ return response, newConvID, nil
+}
+
+func isPromptForImage(prompt string) bool {
+ p := strings.ToLower(prompt)
+ keywords := []string{
+ "generate", "create", "draw", "make", "edit", "add", "remove",
+ "change", "modify", "paint", "cartoon", "3d", "render",
+ "picture", "photo", "illustration", "dall-e", "dalle",
+ }
+ for _, kw := range keywords {
+ if strings.Contains(p, kw) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/chatgpt-free-api/chatgpt.go b/chatgpt-free-api/chatgpt.go
new file mode 100644
index 0000000000000000000000000000000000000000..5ca7857a39cda5b4a99130fee975e106dc607b5f
--- /dev/null
+++ b/chatgpt-free-api/chatgpt.go
@@ -0,0 +1,1009 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// FileAttachment represents an uploaded file to attach to a conversation
+type FileAttachment struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ MimeType string `json:"mime_type"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+}
+
+var (
+ fileIDToPromptMu sync.RWMutex
+ fileIDToPrompt = make(map[string]string)
+)
+
+func registerFilePrompt(fileID, prompt string) {
+ fileIDToPromptMu.Lock()
+ fileIDToPrompt[fileID] = prompt
+ fileIDToPromptMu.Unlock()
+}
+
+func getFilePrompt(fileID string) string {
+ fileIDToPromptMu.RLock()
+ defer fileIDToPromptMu.RUnlock()
+ return fileIDToPrompt[fileID]
+}
+
+// ─── Browser-context API proxy ──────────────────────────────
+
+type apiCallParams struct {
+ URL string
+ Method string
+ Headers map[string]string
+ Body any
+ ResponseType string
+}
+
+type apiCallResult struct {
+ OK bool `json:"ok"`
+ Status int `json:"status"`
+ Headers map[string]string `json:"headers"`
+ Body string `json:"body"`
+ ConversationID string `json:"conversation_id,omitempty"`
+ FinalURL string `json:"finalUrl"`
+ Error string `json:"error"`
+ IsBase64 bool `json:"isBase64,omitempty"`
+ RawText string `json:"rawText,omitempty"`
+}
+
+func callChatGPTAPI(p apiCallParams, timeout time.Duration) (*apiCallResult, error) {
+ if !waitForExtension(cfg.ExtensionWait()) {
+ return nil, fmt.Errorf("extension not connected")
+ }
+ extMu.Lock()
+ conn := extConn
+ extMu.Unlock()
+ if conn == nil {
+ return nil, fmt.Errorf("extension not connected")
+ }
+
+ id := uuid.NewString()
+ ch := make(chan WSMessage, 1)
+ pendingMu.Lock()
+ pending[id] = ch
+ pendingMu.Unlock()
+ defer func() {
+ pendingMu.Lock()
+ delete(pending, id)
+ pendingMu.Unlock()
+ }()
+
+ // Route internal methods directly
+ wsMethod := "api_request"
+ if strings.HasPrefix(p.URL, "__internal__/") {
+ switch {
+ case strings.Contains(p.URL, "solve_pow"):
+ wsMethod = "solve_pow"
+ case strings.Contains(p.URL, "solve_turnstile"):
+ wsMethod = "solve_turnstile"
+ }
+ }
+
+ msg := WSMessage{
+ ID: id,
+ Method: wsMethod,
+ Params: map[string]any{
+ "url": p.URL,
+ "method": p.Method,
+ "headers": p.Headers,
+ "body": p.Body,
+ "responseType": p.ResponseType,
+ },
+ }
+
+ extMu.Lock()
+ err := conn.WriteJSON(msg)
+ extMu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+
+ select {
+ case resp := <-ch:
+ if resp.Error != "" {
+ return nil, fmt.Errorf("%s", resp.Error)
+ }
+ raw, _ := json.Marshal(resp.Result)
+ var r apiCallResult
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return nil, fmt.Errorf("decode api result: %w", err)
+ }
+ return &r, nil
+ case <-time.After(cfg.ChatTimeout()):
+ return nil, fmt.Errorf("timeout waiting for extension api_request")
+ }
+}
+
+// ─── ChatGPT internal API client ─────────────────────────────
+
+type chatGPTRequirements struct {
+ Token string `json:"token"`
+ Proofofwork struct {
+ Required bool `json:"required"`
+ Seed string `json:"seed"`
+ Difficulty string `json:"difficulty"`
+ } `json:"proofofwork"`
+ Turnstile struct {
+ Required bool `json:"required"`
+ } `json:"turnstile"`
+}
+
+func getSessionToken() (string, error) {
+ r, err := callChatGPTAPI(apiCallParams{
+ URL: "https://chatgpt.com/api/auth/session",
+ Method: "GET",
+ }, cfg.APITimeout())
+ if err != nil {
+ return "", err
+ }
+ if r.Status != 200 {
+ return "", fmt.Errorf("session http %d: %s", r.Status, snippet(r.Body, 200))
+ }
+ var s struct {
+ AccessToken string `json:"accessToken"`
+ }
+ if err := json.Unmarshal([]byte(r.Body), &s); err != nil {
+ return "", fmt.Errorf("session decode: %w", err)
+ }
+ if s.AccessToken == "" {
+ return "", fmt.Errorf("no access token (not logged in to chatgpt.com?)")
+ }
+ return s.AccessToken, nil
+}
+
+func getChatRequirements(token string) (*chatGPTRequirements, error) {
+ r, err := callChatGPTAPI(apiCallParams{
+ URL: "https://chatgpt.com/backend-api/sentinel/chat-requirements",
+ Method: "POST",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ },
+ Body: map[string]any{"p": ""},
+ }, cfg.APITimeout())
+ if err != nil {
+ return nil, err
+ }
+ if r.Status != 200 {
+ return nil, fmt.Errorf("requirements http %d: %s", r.Status, snippet(r.Body, 200))
+ }
+ var cr chatGPTRequirements
+ if err := json.Unmarshal([]byte(r.Body), &cr); err != nil {
+ return nil, fmt.Errorf("requirements decode: %w", err)
+ }
+ return &cr, nil
+}
+
+func solveTurnstile() (string, error) {
+ // Delegate to extension — it executes in chatgpt.com tab where turnstile widget exists
+ r, err := callChatGPTAPI(apiCallParams{
+ URL: "__internal__/solve_turnstile",
+ Method: "POST",
+ }, cfg.APITimeout())
+ if err != nil {
+ return "", err
+ }
+ if r.Error != "" {
+ return "", fmt.Errorf("%s", r.Error)
+ }
+ var result struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal([]byte(r.Body), &result); err != nil {
+ return strings.TrimSpace(r.Body), nil
+ }
+ return result.Token, nil
+}
+
+func solveProofOfWork(seed, difficulty string) (string, error) {
+ // Delegate PoW to extension which runs it in browser JS context
+ r, err := callChatGPTAPI(apiCallParams{
+ URL: "__internal__/solve_pow",
+ Method: "POST",
+ Body: map[string]any{"seed": seed, "difficulty": difficulty},
+ }, cfg.ChatTimeout())
+ if err != nil {
+ return "", fmt.Errorf("pow solve: %w", err)
+ }
+ if r.Error != "" {
+ return "", fmt.Errorf("pow solve error: %s", r.Error)
+ }
+ var result struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal([]byte(r.Body), &result); err != nil {
+ // Body itself might be the token
+ return strings.TrimSpace(r.Body), nil
+ }
+ if result.Token != "" {
+ return result.Token, nil
+ }
+ return strings.TrimSpace(r.Body), nil
+}
+
+// sendChatViaFullConversation uses extension's full_conversation method.
+// This does EVERYTHING inside the browser tab context (session, requirements, PoW, turnstile, conversation)
+// which avoids 403 issues because all tokens are generated natively.
+func sendChatViaFullConversation(prompt, conversationID, model, thinkingEffort string, attachments ...FileAttachment) (string, string, string, error) {
+ log.Println("[chat-full] sending full conversation via extension...")
+ if !waitForExtension(cfg.ExtensionWait()) {
+ return "", "", "", fmt.Errorf("extension not connected")
+ }
+ extMu.Lock()
+ conn := extConn
+ extMu.Unlock()
+ if conn == nil {
+ return "", "", "", fmt.Errorf("extension not connected")
+ }
+
+ id := uuid.NewString()
+ ch := make(chan WSMessage, 1)
+ pendingMu.Lock()
+ pending[id] = ch
+ pendingMu.Unlock()
+ defer func() {
+ pendingMu.Lock()
+ delete(pending, id)
+ pendingMu.Unlock()
+ }()
+
+ params := map[string]any{
+ "prompt": prompt,
+ "model": model,
+ "conversation_id": conversationID,
+ "thinking_effort": thinkingEffort,
+ }
+ if len(attachments) > 0 {
+ params["attachments"] = attachments
+ }
+
+ msg := WSMessage{
+ ID: id,
+ Method: "full_conversation",
+ Params: params,
+ }
+
+ extMu.Lock()
+ err := conn.WriteJSON(msg)
+ extMu.Unlock()
+ if err != nil {
+ return "", "", "", err
+ }
+
+ select {
+ case resp := <-ch:
+ if resp.Error != "" {
+ return "", "", "", fmt.Errorf("%s", resp.Error)
+ }
+ raw, _ := json.Marshal(resp.Result)
+ var r apiCallResult
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return "", "", "", fmt.Errorf("decode result: %w", err)
+ }
+ if r.Body == "" {
+ return "", "", "", fmt.Errorf("empty response from full_conversation")
+ }
+ log.Printf("[chat-full] got response (%d chars), conversation_id: %s", len(r.Body), r.ConversationID)
+ return r.Body, r.RawText, r.ConversationID, nil
+ case <-time.After(cfg.ChatTimeout()):
+ return "", "", "", fmt.Errorf("timeout waiting for full_conversation")
+ }
+}
+
+func sendChatViaAPI(prompt string) (string, error) {
+ log.Println("[chat] getting session token...")
+ token, err := getSessionToken()
+ if err != nil {
+ return "", fmt.Errorf("session: %w", err)
+ }
+ log.Println("[chat] getting chat requirements...")
+ cr, err := getChatRequirements(token)
+ if err != nil {
+ return "", fmt.Errorf("requirements: %w", err)
+ }
+
+ headers := map[string]string{
+ "Authorization": "Bearer " + token,
+ "openai-sentinel-chat-requirements-token": cr.Token,
+ "Accept": "text/event-stream",
+ }
+
+ if cr.Proofofwork.Required {
+ log.Printf("[chat] solving proof-of-work (seed=%s, diff=%s)", cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
+ powToken, err := solveProofOfWork(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
+ if err != nil {
+ return "", fmt.Errorf("proof-of-work: %w", err)
+ }
+ headers["openai-sentinel-proof-token"] = powToken
+ }
+ if cr.Turnstile.Required {
+ log.Println("[chat] solving turnstile...")
+ tsToken, err := solveTurnstile()
+ if err != nil {
+ log.Printf("[chat] turnstile failed: %v, attempting without it", err)
+ } else if tsToken != "" {
+ headers["openai-sentinel-turnstile-token"] = tsToken
+ }
+ }
+
+ msgID := uuid.NewString()
+ parentID := uuid.NewString()
+ body := map[string]any{
+ "action": "next",
+ "messages": []any{
+ map[string]any{
+ "id": msgID,
+ "author": map[string]any{"role": "user"},
+ "content": map[string]any{"content_type": "text", "parts": []string{prompt}},
+ "metadata": map[string]any{},
+ "create_time": float64(time.Now().Unix()),
+ },
+ },
+ "parent_message_id": parentID,
+ "model": cfg.DefaultModel,
+ "timezone_offset_min": cfg.TimezoneOffsetMin,
+ "history_and_training_disabled": false,
+ "force_paragen": false,
+ "force_rate_limit": false,
+ "websocket_request_id": uuid.NewString(),
+ "conversation_mode": map[string]any{"kind": "primary_assistant"},
+ "suggestions": []any{},
+ }
+
+ log.Println("[chat] sending conversation request...")
+ r, err := callChatGPTAPI(apiCallParams{
+ URL: "https://chatgpt.com/backend-api/conversation",
+ Method: "POST",
+ Headers: headers,
+ Body: body,
+ }, cfg.ChatTimeout())
+ if err != nil {
+ return "", err
+ }
+ if r.Status != 200 {
+ return "", fmt.Errorf("conversation http %d: %s", r.Status, snippet(r.Body, 400))
+ }
+ out := parseSSEFinal(r.Body)
+ if out == "" {
+ return "", fmt.Errorf("empty assistant response (raw len=%d): %s", len(r.Body), snippet(r.Body, 200))
+ }
+ log.Printf("[chat] got response (%d chars)", len(out))
+ return out, nil
+}
+
+func parseSSEFinal(raw string) string {
+ var snapshot string
+ var delta strings.Builder
+ fileIDs := make(map[string]bool)
+ var currentContentType string
+
+ var processDelta func(ev map[string]any)
+ processDelta = func(ev map[string]any) {
+ vVal, hasV := ev["v"]
+ if !hasV {
+ return
+ }
+ path, hasPath := ev["p"].(string)
+ op, _ := ev["o"].(string)
+
+ if hasPath && strings.Contains(path, "/message/content/parts/0") {
+ if op == "replace" {
+ if s, ok := vVal.(string); ok {
+ delta.Reset()
+ delta.WriteString(s)
+ }
+ } else if op == "" || op == "append" {
+ if s, ok := vVal.(string); ok {
+ delta.WriteString(s)
+ }
+ }
+ } else if !hasPath {
+ if currentContentType == "text" || currentContentType == "multimodal_text" || currentContentType == "" {
+ if s, ok := vVal.(string); ok {
+ delta.WriteString(s)
+ }
+ }
+ } else if op == "patch" {
+ if subList, ok := vVal.([]any); ok {
+ for _, subVal := range subList {
+ if subMap, ok := subVal.(map[string]any); ok {
+ processDelta(subMap)
+ }
+ }
+ }
+ }
+ }
+
+ for _, line := range strings.Split(raw, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ if payload == "" || payload == "[DONE]" {
+ continue
+ }
+ var ev map[string]any
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
+ continue
+ }
+ if vVal, ok := ev["v"].(map[string]any); ok {
+ if msgObj, ok := vVal["message"].(map[string]any); ok {
+ if contentObj, ok := msgObj["content"].(map[string]any); ok {
+ if ct, ok := contentObj["content_type"].(string); ok {
+ currentContentType = ct
+ }
+ }
+ }
+ } else if msgObj, ok := ev["message"].(map[string]any); ok {
+ if contentObj, ok := msgObj["content"].(map[string]any); ok {
+ if ct, ok := contentObj["content_type"].(string); ok {
+ currentContentType = ct
+ }
+ }
+ }
+ if s := extractParts0(ev, fileIDs); s != "" {
+ snapshot = s
+ }
+ if v, ok := ev["v"]; ok {
+ if vm, ok := v.(map[string]any); ok {
+ if s := extractParts0(vm, fileIDs); s != "" {
+ snapshot = s
+ }
+ }
+ }
+ processDelta(ev)
+ }
+ out := snapshot
+ deltaStr := delta.String()
+ if delta.Len() > 0 {
+ isJSONBlock := strings.HasPrefix(deltaStr, `{"`) || strings.HasPrefix(deltaStr, `":"`)
+ if !isJSONBlock {
+ if delta.Len() > len(snapshot) {
+ out = deltaStr
+ } else if !strings.Contains(out, deltaStr) {
+ out += deltaStr
+ }
+ }
+ }
+ if len(fileIDs) > 0 {
+ var imageMarkdown strings.Builder
+ imageMarkdown.WriteString("\n\n")
+ for fileID := range fileIDs {
+ imageMarkdown.WriteString(fmt.Sprintf("\n", cfg.ListenAddr, fileID))
+ }
+ out += imageMarkdown.String()
+ }
+ return out;
+}
+
+func extractParts0(ev map[string]any, fileIDs map[string]bool) string {
+ msg, ok := ev["message"].(map[string]any)
+ if !ok {
+ return ""
+ }
+ content, ok := msg["content"].(map[string]any)
+ if !ok {
+ return ""
+ }
+ parts, ok := content["parts"].([]any)
+ if !ok || len(parts) == 0 {
+ return ""
+ }
+ for _, part := range parts {
+ if partMap, ok := part.(map[string]any); ok {
+ if partMap["content_type"] == "image_asset_pointer" {
+ if assetPtr, ok := partMap["asset_pointer"].(string); ok {
+ if strings.HasPrefix(assetPtr, "file-service://") {
+ fID := strings.TrimPrefix(assetPtr, "file-service://")
+ fileIDs[fID] = true
+ } else if strings.HasPrefix(assetPtr, "sediment://") {
+ fID := strings.TrimPrefix(assetPtr, "sediment://")
+ fileIDs[fID] = true
+ }
+ }
+ }
+ }
+ }
+ s, _ := parts[0].(string)
+ return s
+}
+
+func snippet(s string, n int) string {
+ s = strings.ReplaceAll(s, "\n", " ")
+ if len(s) > n {
+ s = s[:n] + "..."
+ }
+ return s
+}
+
+// downloadChatGPTFile fetches the signed download URL from chatgpt.com backend API,
+// requests it via the browser bridge (forcing base64 response), and decodes it back to raw bytes.
+func downloadChatGPTFile(fileID string) ([]byte, error) {
+ log.Printf("[download] requesting download url for file %s...", fileID)
+ token, err := getSessionToken()
+ if err != nil {
+ return nil, fmt.Errorf("session token: %w", err)
+ }
+
+ downloadURL := fmt.Sprintf("https://chatgpt.com/backend-api/files/%s/download", fileID)
+ r, err := callChatGPTAPI(apiCallParams{
+ URL: downloadURL,
+ Method: "GET",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ },
+ }, cfg.APITimeout())
+ if err != nil {
+ return nil, fmt.Errorf("file download api call: %w", err)
+ }
+ if r.Status != 200 {
+ return nil, fmt.Errorf("file download http %d: %s", r.Status, snippet(r.Body, 200))
+ }
+
+ var dlInfo struct {
+ Status string `json:"status"`
+ DownloadURL string `json:"download_url"`
+ }
+ if err := json.Unmarshal([]byte(r.Body), &dlInfo); err != nil {
+ return nil, fmt.Errorf("decode file download info: %w", err)
+ }
+ if dlInfo.DownloadURL == "" {
+ return nil, fmt.Errorf("empty download url returned: %s", r.Body)
+ }
+
+ log.Printf("[download] downloading binary from signed url: %s", snippet(dlInfo.DownloadURL, 100))
+ rBin, err := callChatGPTAPI(apiCallParams{
+ URL: dlInfo.DownloadURL,
+ Method: "GET",
+ ResponseType: "base64",
+ }, cfg.ChatTimeout())
+ if err != nil {
+ return nil, fmt.Errorf("fetch binary: %w", err)
+ }
+ if rBin.Status != 200 {
+ return nil, fmt.Errorf("fetch binary http %d: %s", rBin.Status, snippet(rBin.Body, 200))
+ }
+
+ if rBin.IsBase64 {
+ data, err := base64.StdEncoding.DecodeString(rBin.Body)
+ if err != nil {
+ return nil, fmt.Errorf("decode base64 response: %w", err)
+ }
+ return data, nil
+ }
+
+ return []byte(rBin.Body), nil
+}
+
+type conversationResponse struct {
+ CurrentNode string `json:"current_node"`
+ Mapping map[string]struct {
+ Message *struct {
+ ID string `json:"id"`
+ Author struct {
+ Role string `json:"role"`
+ } `json:"author"`
+ Status string `json:"status"`
+ Content *struct {
+ ContentType string `json:"content_type"`
+ Parts []any `json:"parts"`
+ } `json:"content"`
+ } `json:"message"`
+ } `json:"mapping"`
+}
+
+func extractImageFileIDs(data []byte) []string {
+ var resp conversationResponse
+ var ids []string
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil
+ }
+ for _, node := range resp.Mapping {
+ if node.Message == nil || node.Message.Content == nil {
+ continue
+ }
+ if node.Message.Content.ContentType == "multimodal_text" {
+ for _, part := range node.Message.Content.Parts {
+ if partMap, ok := part.(map[string]any); ok {
+ if partMap["content_type"] == "image_asset_pointer" {
+ if assetPtr, ok := partMap["asset_pointer"].(string); ok {
+ var id string
+ if strings.HasPrefix(assetPtr, "file-service://") {
+ id = strings.TrimPrefix(assetPtr, "file-service://")
+ } else if strings.HasPrefix(assetPtr, "sediment://") {
+ id = strings.TrimPrefix(assetPtr, "sediment://")
+ }
+ if id != "" {
+ ids = append(ids, id)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ if len(ids) == 0 {
+ var regex = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
+ matches := regex.FindAllString(string(data), -1)
+ seen := make(map[string]bool)
+ for _, m := range matches {
+ if !seen[m] {
+ seen[m] = true
+ ids = append(ids, m)
+ }
+ }
+ }
+ return ids
+}
+
+var sanitizeRegexp = regexp.MustCompile(`[^a-zA-Z0-9\s-_]`)
+var spaceRegexp = regexp.MustCompile(`\s+`)
+
+func getPromptFilename(prompt, fileID string) string {
+ // 1. Lowercase
+ s := strings.ToLower(prompt)
+
+ // 2. Remove common command/generation prefixes
+ prefixes := []string{
+ "generate an image:",
+ "generate a image:",
+ "generate image:",
+ "create an image:",
+ "create a image:",
+ "create image:",
+ "draw an image:",
+ "draw a image:",
+ "draw image:",
+ "make an image:",
+ "make a image:",
+ "make image:",
+ "generate a drawing of",
+ "generate an image of",
+ "generate drawing of",
+ "generate image of",
+ "generate a",
+ "generate an",
+ "generate",
+ "create an image of",
+ "create a drawing of",
+ "create image of",
+ "create drawing of",
+ "create a",
+ "create",
+ "draw a",
+ "draw",
+ }
+ for _, prefix := range prefixes {
+ if strings.HasPrefix(s, prefix) {
+ s = strings.TrimPrefix(s, prefix)
+ break
+ }
+ }
+ s = strings.TrimSpace(s)
+
+ // 2b. Strip leading articles (a, an, the) for cleaner names
+ articles := []string{"a ", "an ", "the "}
+ for _, art := range articles {
+ if strings.HasPrefix(s, art) {
+ s = strings.TrimPrefix(s, art)
+ break
+ }
+ }
+ s = strings.TrimSpace(s)
+
+ // 3. Remove non-alphanumeric characters
+ s = sanitizeRegexp.ReplaceAllString(s, "")
+
+ // 4. Replace spaces with underscores
+ s = spaceRegexp.ReplaceAllString(s, "_")
+
+ // 5. Keep only first 3 words for a clean short name
+ parts := strings.SplitN(s, "_", 4)
+ if len(parts) > 3 {
+ parts = parts[:3]
+ }
+ s = strings.Join(parts, "_")
+
+ if s == "" {
+ s = "image"
+ }
+
+ // 6. Append short file suffix for uniqueness (last 8 characters of ID)
+ suffix := fileID
+ if len(suffix) > 8 {
+ suffix = suffix[len(suffix)-8:]
+ }
+
+ return fmt.Sprintf("%s_%s", s, suffix)
+}
+
+func pollForImage(prompt, conversationID string, timeout time.Duration, excludeIDs ...string) (string, error) {
+ deadline := time.Now().Add(timeout)
+
+ excludeMap := make(map[string]bool)
+ for _, id := range excludeIDs {
+ excludeMap[id] = true
+ }
+
+ // Track all generated image IDs we've found and processed
+ foundImages := make(map[string]bool)
+ var generatedIDs []string
+
+ for time.Now().Before(deadline) {
+ token, err := getSessionToken()
+ if err != nil {
+ time.Sleep(1 * time.Second)
+ continue
+ }
+ res, err := callChatGPTAPI(apiCallParams{
+ URL: "https://chatgpt.com/backend-api/conversation/" + conversationID,
+ Method: "GET",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ },
+ }, cfg.APITimeout())
+ if err != nil {
+ log.Printf("[poll] ❌ callChatGPTAPI failed: %v", err)
+ time.Sleep(1 * time.Second)
+ continue
+ }
+ log.Printf("[poll] Polled conversation %s, status=%d, bodyLen=%d", conversationID, res.Status, len(res.Body))
+ if res.Status == 200 {
+ _ = os.WriteFile("debug_poll.json", []byte(res.Body), 0644)
+
+ var pollResp conversationResponse
+ if err := json.Unmarshal([]byte(res.Body), &pollResp); err == nil {
+ ids := extractImageFileIDs([]byte(res.Body))
+
+ // Find any new generated image IDs we haven't seen yet
+ var newIDs []string
+ for _, id := range ids {
+ if !excludeMap[id] && !foundImages[id] {
+ foundImages[id] = true
+ newIDs = append(newIDs, id)
+ generatedIDs = append(generatedIDs, id)
+ }
+ }
+
+ // Download any new images immediately
+ for _, id := range newIDs {
+ registerFilePrompt(id, prompt)
+ name := getPromptFilename(prompt, id)
+ log.Printf("[auto-download] Auto-downloading generated image %s (%s)...", id, name)
+
+ var data []byte
+ var dlErr error
+ // Try to download with retries (in case the file metadata is created but binary isn't fully ready yet on OpenAI backend)
+ for attempt := 1; attempt <= 5; attempt++ {
+ data, dlErr = downloadChatGPTFile(id)
+ if dlErr == nil {
+ break
+ }
+ time.Sleep(1 * time.Second)
+ }
+ if dlErr == nil {
+ _ = os.MkdirAll("output", 0755)
+ localPath := "output/" + name + ".png"
+ dlErr = os.WriteFile(localPath, data, 0644)
+ if dlErr != nil {
+ log.Printf("[auto-download] Error saving file %s locally: %v", localPath, dlErr)
+ } else {
+ log.Printf("[auto-download] Successfully saved image to %s", localPath)
+ }
+ } else {
+ log.Printf("[auto-download] Error downloading image %s: %v", id, dlErr)
+ }
+ }
+
+ // Break early if we found new generated images
+ if len(generatedIDs) > 0 {
+ log.Printf("[poll] Found generated images: %v. Stopping poll.", generatedIDs)
+ break
+ }
+
+ // Check if the conversation leaf is finished and not generating
+ if leaf, exists := pollResp.Mapping[pollResp.CurrentNode]; exists && leaf.Message != nil {
+ role := leaf.Message.Author.Role
+ status := leaf.Message.Status
+
+ isFinishedAssistant := role == "assistant" && status == "finished_successfully"
+ isFinishedToolWithImage := role == "tool" && status == "finished_successfully" &&
+ leaf.Message.Content != nil && hasImagePointer(leaf.Message.Content.Parts)
+
+ if isFinishedAssistant || isFinishedToolWithImage {
+ log.Printf("[poll] Conversation leaf is finished (%s, %s). Stopping poll.", role, status)
+ break
+ }
+ }
+ }
+ }
+ time.Sleep(1500 * time.Millisecond)
+ }
+
+ // Compile and return the markdown response for all generated images
+ if len(generatedIDs) > 0 {
+ var markdown strings.Builder
+ markdown.WriteString("Here is your generated image:\n\n")
+ for _, id := range generatedIDs {
+ markdown.WriteString(fmt.Sprintf("\n", id, url.QueryEscape(prompt)))
+ }
+ return markdown.String(), nil
+ }
+
+ return "", fmt.Errorf("timeout waiting for image generation in conversation %s", conversationID)
+}
+
+func hasImagePointer(parts []any) bool {
+ for _, part := range parts {
+ if partMap, ok := part.(map[string]any); ok {
+ if partMap["content_type"] == "image_asset_pointer" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// uploadFileToChatGPT uploads a local file to ChatGPT via the backend API and returns a FileAttachment.
+// It uses the extension's api_request method to call /backend-api/files with proper auth.
+func uploadFileToChatGPT(filePath string) (*FileAttachment, error) {
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("read file: %w", err)
+ }
+
+ fileName := filepath.Base(filePath)
+ mimeType := "image/png"
+ ext := strings.ToLower(filepath.Ext(fileName))
+ switch ext {
+ case ".jpg", ".jpeg":
+ mimeType = "image/jpeg"
+ case ".webp":
+ mimeType = "image/webp"
+ case ".gif":
+ mimeType = "image/gif"
+ }
+
+ fileSize := int64(len(data))
+
+ log.Printf("[upload] Uploading file %s (%d bytes, %s) to ChatGPT...", fileName, fileSize, mimeType)
+
+ // Step 1: Create file upload via backend API
+ token, err := getSessionToken()
+ if err != nil {
+ return nil, fmt.Errorf("get session token: %w", err)
+ }
+
+ createBody := map[string]any{
+ "file_name": fileName,
+ "file_size": fileSize,
+ "use_case": "multimodal",
+ }
+ bodyJSON, _ := json.Marshal(createBody)
+ res, err := callChatGPTAPI(apiCallParams{
+ URL: "https://chatgpt.com/backend-api/files",
+ Method: "POST",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ "Content-Type": "application/json",
+ },
+ Body: string(bodyJSON),
+ }, cfg.APITimeout())
+ if err != nil {
+ return nil, fmt.Errorf("create file: %w", err)
+ }
+ if res.Status != 200 {
+ return nil, fmt.Errorf("create file status %d: %s", res.Status, res.Body[:min(len(res.Body), 500)])
+ }
+
+ var createResp struct {
+ FileID string `json:"file_id"`
+ UploadURL string `json:"upload_url"`
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal([]byte(res.Body), &createResp); err != nil {
+ return nil, fmt.Errorf("parse create response: %w", err)
+ }
+
+ log.Printf("[upload] File created with ID: %s, upload URL: %s", createResp.FileID, createResp.UploadURL[:min(len(createResp.UploadURL), 80)])
+
+ // Step 2: Upload file data to the upload URL via extension (browser context for proper auth)
+ b64Data := base64.StdEncoding.EncodeToString(data)
+ uploadRes, err := callChatGPTAPI(apiCallParams{
+ URL: createResp.UploadURL,
+ Method: "PUT",
+ Headers: map[string]string{
+ "Content-Type": mimeType,
+ "X-Ms-Blob-Type": "BlockBlob",
+ "X-Ms-Version": "2020-04-08",
+ },
+ Body: b64Data,
+ }, cfg.APITimeout())
+ if err != nil {
+ return nil, fmt.Errorf("upload file data: %w", err)
+ }
+ if uploadRes.Status >= 300 {
+ return nil, fmt.Errorf("upload file data status %d: %s", uploadRes.Status, uploadRes.Body[:min(len(uploadRes.Body), 200)])
+ }
+
+ log.Printf("[upload] File data uploaded successfully, marking as uploaded...")
+
+ // Step 3: Mark file as uploaded
+ markBody := map[string]any{}
+ markJSON, _ := json.Marshal(markBody)
+ markRes, err := callChatGPTAPI(apiCallParams{
+ URL: fmt.Sprintf("https://chatgpt.com/backend-api/files/%s/uploaded", createResp.FileID),
+ Method: "POST",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ "Content-Type": "application/json",
+ },
+ Body: string(markJSON),
+ }, cfg.APITimeout())
+ if err != nil {
+ log.Printf("[upload] Warning: mark uploaded failed: %v", err)
+ } else {
+ log.Printf("[upload] Mark uploaded response: status=%d", markRes.Status)
+ }
+
+ // Step 4: Wait for processing
+ for i := 0; i < 10; i++ {
+ time.Sleep(1 * time.Second)
+ checkRes, err := callChatGPTAPI(apiCallParams{
+ URL: fmt.Sprintf("https://chatgpt.com/backend-api/files/%s", createResp.FileID),
+ Method: "GET",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ },
+ }, cfg.APITimeout())
+ if err != nil {
+ continue
+ }
+ var fileStatus struct {
+ Status string `json:"status"`
+ FileID string `json:"file_id"`
+ }
+ if err := json.Unmarshal([]byte(checkRes.Body), &fileStatus); err == nil {
+ log.Printf("[upload] File %s status: %s", createResp.FileID, fileStatus.Status)
+ if fileStatus.Status == "success" || fileStatus.Status == "ready" {
+ break
+ }
+ }
+ }
+
+ log.Printf("[upload] File upload complete: %s (%s)", createResp.FileID, fileName)
+
+ return &FileAttachment{
+ ID: createResp.FileID,
+ Name: fileName,
+ Size: fileSize,
+ MimeType: mimeType,
+ }, nil
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
diff --git a/chatgpt-free-api/config.go b/chatgpt-free-api/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..f3348ad83b3df2ad95bc34de5f00eb0fdf805623
--- /dev/null
+++ b/chatgpt-free-api/config.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// AppConfig holds all configurable values for the agent.
+type AppConfig struct {
+ ListenAddr string `json:"listen_addr"`
+ DefaultModel string `json:"default_model"`
+ DefaultThinkingEffort string `json:"default_thinking_effort"`
+ TimeoutSeconds int `json:"timeout_seconds"`
+ ExtensionWaitSeconds int `json:"extension_wait_seconds"`
+ ChatTimeoutSeconds int `json:"chat_timeout_seconds"`
+ APITimeoutSeconds int `json:"api_timeout_seconds"`
+ MaxSniffs int `json:"max_sniffs"`
+ BulkDelaySeconds int `json:"bulk_delay_seconds"`
+ BrowserWakeCooldownSecs int `json:"browser_wake_cooldown_seconds"`
+ TimezoneOffsetMin int `json:"timezone_offset_min"`
+ RawSSEOutputPath string `json:"raw_sse_output_path"`
+}
+
+// Convenience duration helpers
+func (c *AppConfig) Timeout() time.Duration {
+ return time.Duration(c.TimeoutSeconds) * time.Second
+}
+
+func (c *AppConfig) ExtensionWait() time.Duration {
+ return time.Duration(c.ExtensionWaitSeconds) * time.Second
+}
+
+func (c *AppConfig) ChatTimeout() time.Duration {
+ return time.Duration(c.ChatTimeoutSeconds) * time.Second
+}
+
+func (c *AppConfig) APITimeout() time.Duration {
+ return time.Duration(c.APITimeoutSeconds) * time.Second
+}
+
+func (c *AppConfig) BrowserWakeCooldown() time.Duration {
+ return time.Duration(c.BrowserWakeCooldownSecs) * time.Second
+}
+
+// cfg is the global config instance used throughout the app.
+var cfg = defaultConfig()
+
+func defaultConfig() AppConfig {
+ return AppConfig{
+ ListenAddr: "127.0.0.1:9224",
+ DefaultModel: "auto",
+ DefaultThinkingEffort: "",
+ TimeoutSeconds: 300,
+ ExtensionWaitSeconds: 10,
+ ChatTimeoutSeconds: 240,
+ APITimeoutSeconds: 30,
+ MaxSniffs: 250,
+ BulkDelaySeconds: 2,
+ BrowserWakeCooldownSecs: 60,
+ TimezoneOffsetMin: -330,
+ RawSSEOutputPath: "",
+ }
+}
+
+// loadConfig reads config.json from the same directory as the executable.
+// If the file doesn't exist, defaults are used silently.
+func loadConfig() {
+ // Try config.json next to the binary first, then in CWD
+ paths := []string{}
+
+ if exe, err := os.Executable(); err == nil {
+ paths = append(paths, filepath.Join(filepath.Dir(exe), "config.json"))
+ }
+ paths = append(paths, "config.json")
+
+ var data []byte
+ var loadedPath string
+ for _, p := range paths {
+ d, err := os.ReadFile(p)
+ if err == nil {
+ data = d
+ loadedPath = p
+ break
+ }
+ }
+
+ if data == nil {
+ log.Println("[config] No config.json found, using defaults")
+ return
+ }
+
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ log.Printf("[config] Error parsing %s: %v — using defaults", loadedPath, err)
+ cfg = defaultConfig()
+ return
+ }
+
+ log.Printf("[config] Loaded from %s", loadedPath)
+}
diff --git a/chatgpt-free-api/config.json b/chatgpt-free-api/config.json
new file mode 100644
index 0000000000000000000000000000000000000000..ee7475ad4a10fabb267f45dc04eab2e80a1707c3
--- /dev/null
+++ b/chatgpt-free-api/config.json
@@ -0,0 +1,14 @@
+{
+ "listen_addr": "127.0.0.1:9225",
+ "default_model": "gpt-5-5",
+ "default_thinking_effort": "",
+ "timeout_seconds": 300,
+ "extension_wait_seconds": 10,
+ "chat_timeout_seconds": 240,
+ "api_timeout_seconds": 30,
+ "max_sniffs": 250,
+ "bulk_delay_seconds": 2,
+ "browser_wake_cooldown_seconds": 60,
+ "timezone_offset_min": -330,
+ "raw_sse_output_path": "/Users/akashyadav/.gemini/antigravity-ide/scratch/raw_sse.txt"
+}
diff --git a/chatgpt-free-api/cookies.go b/chatgpt-free-api/cookies.go
new file mode 100644
index 0000000000000000000000000000000000000000..79450f1f45c5428ea2175eb3712228df99c6cea5
--- /dev/null
+++ b/chatgpt-free-api/cookies.go
@@ -0,0 +1,477 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// CookieObject represents a browser cookie from the extension
+type CookieObject struct {
+ Domain string `json:"domain"`
+ ExpirationDate float64 `json:"expirationDate,omitempty"`
+ HostOnly bool `json:"hostOnly,omitempty"`
+ HttpOnly bool `json:"httpOnly,omitempty"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ SameSite string `json:"sameSite,omitempty"`
+ Secure bool `json:"secure,omitempty"`
+ Session bool `json:"session,omitempty"`
+ StoreId string `json:"storeId,omitempty"`
+ Value string `json:"value"`
+}
+
+type CookiePayloadMessage struct {
+ Type string `json:"type"`
+ Cookies []CookieObject `json:"cookies"`
+}
+
+const CookiesFile = "cookies.json"
+
+var (
+ cachedCookies []CookieObject
+ cachedCookiesMu sync.RWMutex
+ lastCookieSync time.Time
+)
+
+// GetCachedCookieHeader returns a cookie header string for HTTP requests to chatgpt.com
+func GetCachedCookieHeader() string {
+ cachedCookiesMu.RLock()
+ defer cachedCookiesMu.RUnlock()
+ var parts []string
+ for _, ck := range cachedCookies {
+ parts = append(parts, fmt.Sprintf("%s=%s", ck.Name, ck.Value))
+ }
+ return strings.Join(parts, "; ")
+}
+
+// GetCookieValue returns the value of a specific cookie by name
+func GetCookieValue(name string) string {
+ cachedCookiesMu.RLock()
+ defer cachedCookiesMu.RUnlock()
+ for _, ck := range cachedCookies {
+ if ck.Name == name {
+ return ck.Value
+ }
+ }
+ return ""
+}
+
+// HasCookies returns true if we have synced cookies
+func HasCookies() bool {
+ cachedCookiesMu.RLock()
+ defer cachedCookiesMu.RUnlock()
+ return len(cachedCookies) > 0
+}
+
+// GetLastCookieSync returns the time of the last cookie sync
+func GetLastCookieSync() time.Time {
+ cachedCookiesMu.RLock()
+ defer cachedCookiesMu.RUnlock()
+ return lastCookieSync
+}
+
+// LoadCookiesFromFile loads cookies from cookies.json at startup
+func LoadCookiesFromFile() {
+ data, err := os.ReadFile(CookiesFile)
+ if err != nil {
+ log.Printf("[cookies] No existing cookies.json found: %v", err)
+ return
+ }
+ var cookies []CookieObject
+ if err := json.Unmarshal(data, &cookies); err != nil {
+ log.Printf("[cookies] Error parsing cookies.json: %v", err)
+ return
+ }
+ cachedCookiesMu.Lock()
+ cachedCookies = cookies
+ lastCookieSync = time.Now()
+ cachedCookiesMu.Unlock()
+ log.Printf("[cookies] Loaded %d cookies from disk", len(cookies))
+}
+
+// HandleCookiePayload processes cookies received from the extension
+func HandleCookiePayload(cookies []CookieObject) {
+ log.Printf("🍪 Received %d cookies from extension", len(cookies))
+
+ // Save to cookies.json
+ data, err := json.MarshalIndent(cookies, "", " ")
+ if err != nil {
+ log.Printf("❌ Failed to marshal cookies: %v", err)
+ return
+ }
+
+ if err := os.WriteFile(CookiesFile, data, 0644); err != nil {
+ log.Printf("❌ Failed to save cookies.json: %v", err)
+ return
+ }
+
+ // Update in-memory cache
+ cachedCookiesMu.Lock()
+ cachedCookies = cookies
+ lastCookieSync = time.Now()
+ cachedCookiesMu.Unlock()
+
+ log.Printf("🍪 Cached %d cookies for direct API calls", len(cookies))
+}
+
+// ─── Direct HTTP client using cookies ───────────────────────────
+
+// DirectHTTPResponse holds the response from a direct HTTP request
+type DirectHTTPResponse struct {
+ Status int `json:"status"`
+ Body string `json:"body"`
+ Headers map[string]string `json:"headers"`
+}
+
+// DirectHTTPRequest makes an HTTP request to chatgpt.com using synced cookies
+func DirectHTTPRequest(method, urlStr string, headers map[string]string, body string) (*DirectHTTPResponse, error) {
+ if !HasCookies() {
+ return nil, fmt.Errorf("no cookies available — extension has not synced yet")
+ }
+
+ var bodyReader io.Reader
+ if body != "" {
+ bodyReader = strings.NewReader(body)
+ }
+
+ req, err := http.NewRequest(method, urlStr, bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ // Set cookies
+ req.Header.Set("Cookie", GetCachedCookieHeader())
+
+ // Set default headers for ChatGPT
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Accept", "*/*")
+ req.Header.Set("Accept-Language", "en-US,en;q=0.9")
+ req.Header.Set("Origin", "https://chatgpt.com")
+ req.Header.Set("Referer", "https://chatgpt.com/")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="146", "Google Chrome";v="146"`)
+ req.Header.Set("sec-ch-ua-mobile", "?0")
+ req.Header.Set("sec-ch-ua-platform", `"macOS"`)
+
+ // Set oai-did from cookies
+ oaiDid := GetCookieValue("oai-did")
+ if oaiDid != "" {
+ req.Header.Set("OAI-Device-Id", oaiDid)
+ }
+ req.Header.Set("OAI-Language", "en-US")
+
+ // Override with custom headers
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ client := &http.Client{
+ Timeout: 120 * time.Second,
+ }
+
+ log.Printf("[direct-http] Requesting %s %s...", method, urlStr)
+ resp, err := client.Do(req)
+ if err != nil {
+ log.Printf("[direct-http] ❌ Connection error: %v", err)
+ return nil, fmt.Errorf("http request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Printf("[direct-http] ❌ Read response error: %v", err)
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ log.Printf("[direct-http] Response status: %d (body length: %d bytes)", resp.StatusCode, len(respBody))
+
+ respHeaders := make(map[string]string)
+ for k, v := range resp.Header {
+ if len(v) > 0 {
+ respHeaders[k] = v[0]
+ }
+ }
+
+ return &DirectHTTPResponse{
+ Status: resp.StatusCode,
+ Body: string(respBody),
+ Headers: respHeaders,
+ }, nil
+}
+
+// ─── Direct ChatGPT API Functions ───────────────────────────────
+
+// GetSessionTokenDirect gets the session token directly using cookies (no extension needed)
+func GetSessionTokenDirect() (string, error) {
+ resp, err := DirectHTTPRequest("GET", "https://chatgpt.com/api/auth/session", nil, "")
+ if err != nil {
+ return "", fmt.Errorf("session request: %w", err)
+ }
+ if resp.Status != 200 {
+ return "", fmt.Errorf("session http %d: %s", resp.Status, snippet(resp.Body, 200))
+ }
+ var s struct {
+ AccessToken string `json:"accessToken"`
+ }
+ if err := json.Unmarshal([]byte(resp.Body), &s); err != nil {
+ return "", fmt.Errorf("session decode: %w", err)
+ }
+ if s.AccessToken == "" {
+ return "", fmt.Errorf("no access token (not logged in)")
+ }
+ return s.AccessToken, nil
+}
+
+// GetChatRequirementsDirect gets chat requirements directly using cookies
+func GetChatRequirementsDirect(token string) (*chatGPTRequirements, error) {
+ resp, err := DirectHTTPRequest("POST", "https://chatgpt.com/backend-api/sentinel/chat-requirements",
+ map[string]string{
+ "Authorization": "Bearer " + token,
+ "Content-Type": "application/json",
+ },
+ `{"p":""}`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if resp.Status != 200 {
+ return nil, fmt.Errorf("requirements http %d: %s", resp.Status, snippet(resp.Body, 200))
+ }
+ var cr chatGPTRequirements
+ if err := json.Unmarshal([]byte(resp.Body), &cr); err != nil {
+ return nil, fmt.Errorf("requirements decode: %w", err)
+ }
+ return &cr, nil
+}
+
+// SendChatDirect sends a conversation request directly to ChatGPT using cookies (no extension needed)
+// Returns: response text, rawSSE, conversation_id, error
+func SendChatDirect(prompt, conversationID, model, thinkingEffort string) (string, string, string, error) {
+ if !HasCookies() {
+ return "", "", "", fmt.Errorf("no cookies — extension has not synced")
+ }
+
+ log.Println("[direct] Getting session token...")
+ token, err := GetSessionTokenDirect()
+ if err != nil {
+ log.Printf("[direct] ❌ Session token fetch failed: %v", err)
+ return "", "", "", fmt.Errorf("session: %w", err)
+ }
+
+ log.Println("[direct] Getting chat requirements...")
+ cr, err := GetChatRequirementsDirect(token)
+ if err != nil {
+ log.Printf("[direct] ❌ Chat requirements fetch failed: %v", err)
+ return "", "", "", fmt.Errorf("requirements: %w", err)
+ }
+
+ headers := map[string]string{
+ "Authorization": "Bearer " + token,
+ "Content-Type": "application/json",
+ "Accept": "text/event-stream",
+ "openai-sentinel-chat-requirements-token": cr.Token,
+ }
+
+ // PoW solving
+ if cr.Proofofwork.Required {
+ log.Printf("[direct] Solving proof-of-work (seed=%s, diff=%s)", cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
+ powToken := SolveFNVPow(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
+ if powToken != "" {
+ headers["openai-sentinel-proof-token"] = powToken
+ } else {
+ log.Println("[direct] PoW solve failed, proceeding without it")
+ }
+ }
+
+ // Turnstile — can't solve from Go, skip
+ if cr.Turnstile.Required {
+ log.Println("[direct] ⚠️ Turnstile required — cannot solve from Go, falling back to extension")
+ return "", "", "", fmt.Errorf("TURNSTILE_REQUIRED")
+ }
+
+ // Resolve parent message ID
+ parentID := "client-created-root"
+ if conversationID != "" {
+ convResp, err := DirectHTTPRequest("GET",
+ "https://chatgpt.com/backend-api/conversation/"+conversationID,
+ map[string]string{"Authorization": "Bearer " + token},
+ "",
+ )
+ if err == nil && convResp.Status == 200 {
+ var convData struct {
+ CurrentNode string `json:"current_node"`
+ }
+ if json.Unmarshal([]byte(convResp.Body), &convData) == nil && convData.CurrentNode != "" {
+ parentID = convData.CurrentNode
+ }
+ }
+ }
+
+ // Build conversation body
+ actualModel := model
+ var resolvedThinkingEffort string
+ if thinkingEffort != "" {
+ resolvedThinkingEffort = thinkingEffort
+ }
+ if strings.Contains(model, "thinking") {
+ if strings.Contains(model, "-extended") {
+ resolvedThinkingEffort = "extended"
+ actualModel = strings.Replace(model, "-extended", "", 1)
+ } else if strings.Contains(model, "-standard") {
+ resolvedThinkingEffort = "standard"
+ actualModel = strings.Replace(model, "-standard", "", 1)
+ } else if resolvedThinkingEffort == "" {
+ resolvedThinkingEffort = "standard"
+ }
+ }
+
+ convBody := map[string]any{
+ "action": "next",
+ "messages": []any{
+ map[string]any{
+ "id": uuid.NewString(),
+ "author": map[string]any{"role": "user"},
+ "content": map[string]any{
+ "content_type": "text",
+ "parts": []string{prompt},
+ },
+ "metadata": map[string]any{},
+ "create_time": float64(time.Now().Unix()),
+ },
+ },
+ "parent_message_id": parentID,
+ "model": actualModel,
+ "timezone_offset_min": cfg.TimezoneOffsetMin,
+ "timezone": "Asia/Kolkata",
+ "history_and_training_disabled": false,
+ "fork_from_shared_post": false,
+ "force_paragen": false,
+ "force_rate_limit": false,
+ "conversation_mode": map[string]any{"kind": "primary_assistant"},
+ "enable_message_followups": true,
+ "system_hints": []any{},
+ "supports_buffering": true,
+ "supported_encodings": []string{"v1"},
+ "paragen_cot_summary_display_override": "allow",
+ "force_parallel_switch": "auto",
+ "websocket_request_id": uuid.NewString(),
+ "client_contextual_info": map[string]any{
+ "is_dark_mode": false,
+ "time_since_loaded": 0,
+ "page_height": 800,
+ "page_width": 1200,
+ "pixel_ratio": 1,
+ "screen_height": 1080,
+ "screen_width": 1920,
+ },
+ }
+ if conversationID != "" {
+ convBody["conversation_id"] = conversationID
+ }
+ if resolvedThinkingEffort != "" {
+ convBody["thinking_effort"] = resolvedThinkingEffort
+ }
+
+ bodyJSON, _ := json.Marshal(convBody)
+
+ log.Println("[direct] Sending conversation request...")
+ resp, err := DirectHTTPRequest("POST", "https://chatgpt.com/backend-api/conversation", headers, string(bodyJSON))
+ if err != nil {
+ log.Printf("[direct] ❌ Conversation request failed: %v", err)
+ return "", "", "", fmt.Errorf("conversation request: %w", err)
+ }
+ if resp.Status != 200 {
+ log.Printf("[direct] ❌ Conversation HTTP %d: %s", resp.Status, snippet(resp.Body, 400))
+ return "", "", "", fmt.Errorf("conversation http %d: %s", resp.Status, snippet(resp.Body, 400))
+ }
+
+ // Parse SSE response
+ text := parseSSEFinal(resp.Body)
+ if text == "" {
+ return "", resp.Body, "", fmt.Errorf("empty assistant response (raw len=%d)", len(resp.Body))
+ }
+
+ // Extract conversation_id from SSE
+ newConvID := extractConversationID(resp.Body)
+
+ // Save raw SSE if configured
+ if cfg.RawSSEOutputPath != "" {
+ _ = os.WriteFile(cfg.RawSSEOutputPath, []byte(resp.Body), 0644)
+ }
+
+ log.Printf("[direct] Got response (%d chars), conversation_id: %s", len(text), newConvID)
+ return text, resp.Body, newConvID, nil
+}
+
+// extractConversationID pulls conversation_id from SSE stream
+func extractConversationID(raw string) string {
+ for _, line := range strings.Split(raw, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ if payload == "" || payload == "[DONE]" {
+ continue
+ }
+ var ev map[string]any
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
+ continue
+ }
+ if cid, ok := ev["conversation_id"].(string); ok && cid != "" {
+ return cid
+ }
+ if v, ok := ev["v"].(map[string]any); ok {
+ if cid, ok := v["conversation_id"].(string); ok && cid != "" {
+ return cid
+ }
+ }
+ }
+ return ""
+}
+
+// ─── FNV PoW Solver ─────────────────────────────────────────────
+
+// SolveFNVPow solves ChatGPT's FNV-based proof of work natively in Go
+func SolveFNVPow(seed, difficulty string) string {
+ for nonce := 0; nonce < 500000; nonce++ {
+ // Build a config array matching the browser's format
+ config := fmt.Sprintf(`[3000,"%s",4294705152,%d,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",null,"","en-US","en-US",%d,"hardwareConcurrency−8","location","self",0,"%s","",8,%d]`,
+ time.Now().String(), nonce, nonce, uuid.NewString(), time.Now().UnixMilli())
+
+ encoded := base64.StdEncoding.EncodeToString([]byte(config))
+ input := seed + encoded
+
+ hash := fnvHash(input)
+ if len(hash) >= len(difficulty) && hash[:len(difficulty)] <= difficulty {
+ return "gAAAAAB" + encoded + "~S"
+ }
+ }
+ return ""
+}
+
+func fnvHash(input string) string {
+ h := uint32(2166136261)
+ for i := 0; i < len(input); i++ {
+ h ^= uint32(input[i])
+ h *= 16777619
+ }
+ h ^= h >> 16
+ h *= 2246822507
+ h ^= h >> 13
+ h *= 3266489909
+ h ^= h >> 16
+ return fmt.Sprintf("%08x", h)
+}
diff --git a/chatgpt-free-api/cookies_test.go b/chatgpt-free-api/cookies_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..5e510198ed82b0b460413f1e6b10231830b3b768
--- /dev/null
+++ b/chatgpt-free-api/cookies_test.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestCookiesLifecycle(t *testing.T) {
+ // 1. Prepare dummy cookies
+ dummyCookies := []CookieObject{
+ {
+ Domain: ".chatgpt.com",
+ Name: "oai-did",
+ Value: "test-device-id-123",
+ },
+ {
+ Domain: ".chatgpt.com",
+ Name: "__Secure-next-auth.session-token",
+ Value: "test-session-token-abc",
+ },
+ }
+
+ // 2. Write dummy cookies to test file path (temporarily override CookiesFile or use the function)
+ // Since CookiesFile is a constant ("cookies.json"), we should back up existing cookies.json if it exists,
+ // write our dummy data, run the test, and restore it.
+ const testFile = "cookies.json"
+ var backupData []byte
+ backupExists := false
+
+ if _, err := os.Stat(testFile); err == nil {
+ backupData, err = os.ReadFile(testFile)
+ if err == nil {
+ backupExists = true
+ }
+ }
+
+ // Clean up after test
+ defer func() {
+ if backupExists {
+ _ = os.WriteFile(testFile, backupData, 0644)
+ } else {
+ _ = os.Remove(testFile)
+ }
+ }()
+
+ // Write test cookies
+ data, err := json.Marshal(dummyCookies)
+ if err != nil {
+ t.Fatalf("Failed to marshal dummy cookies: %v", err)
+ }
+ if err := os.WriteFile(testFile, data, 0644); err != nil {
+ t.Fatalf("Failed to write test cookies.json: %v", err)
+ }
+
+ // 3. Test LoadCookiesFromFile
+ LoadCookiesFromFile()
+
+ if !HasCookies() {
+ t.Errorf("Expected HasCookies() to be true, got false")
+ }
+
+ // 4. Test GetCookieValue
+ oaiDid := GetCookieValue("oai-did")
+ if oaiDid != "test-device-id-123" {
+ t.Errorf("Expected oai-did to be 'test-device-id-123', got '%s'", oaiDid)
+ }
+
+ // 5. Test GetCachedCookieHeader
+ header := GetCachedCookieHeader()
+ if !strings.Contains(header, "oai-did=test-device-id-123") {
+ t.Errorf("Expected header to contain 'oai-did=test-device-id-123', got '%s'", header)
+ }
+ if !strings.Contains(header, "__Secure-next-auth.session-token=test-session-token-abc") {
+ t.Errorf("Expected header to contain session token, got '%s'", header)
+ }
+
+ // 6. Test HandleCookiePayload directly
+ newDummyCookies := []CookieObject{
+ {
+ Domain: ".chatgpt.com",
+ Name: "oai-did",
+ Value: "updated-device-id",
+ },
+ }
+ HandleCookiePayload(newDummyCookies)
+
+ updatedOaiDid := GetCookieValue("oai-did")
+ if updatedOaiDid != "updated-device-id" {
+ t.Errorf("Expected updated oai-did to be 'updated-device-id', got '%s'", updatedOaiDid)
+ }
+}
+
+func TestSolveFNVPow(t *testing.T) {
+ // Verify that the solver functions and can find a solution for a very easy difficulty
+ // Or at least it doesn't crash.
+ // Difficulty is a hex string prefix. Let's pass a very high/easy difficulty threshold
+ // to make sure it finishes quickly. "ffff" means any hash <= "ffff" (which is almost all of them since it's 8 hex chars max).
+ // Let's use "9" as difficulty which is easy enough to solve in a few nonces.
+ token := SolveFNVPow("0.abcdef123456", "9")
+ t.Logf("Solved PoW token: %s", token)
+ // It should either solve it or exit. Let's make sure it doesn't crash.
+}
diff --git a/chatgpt-free-api/extension.go b/chatgpt-free-api/extension.go
new file mode 100644
index 0000000000000000000000000000000000000000..eaf93c58d4dfb94ab7675006b081a0713858e240
--- /dev/null
+++ b/chatgpt-free-api/extension.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "os"
+ "os/exec"
+ "runtime"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+type WSMessage struct {
+ Type string `json:"type,omitempty"`
+ Method string `json:"method,omitempty"`
+ ID string `json:"id,omitempty"`
+ Params map[string]any `json:"params,omitempty"`
+ Result any `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+var (
+ extConn *websocket.Conn
+ extMu sync.Mutex
+ reloadTriggered bool
+ pendingMu sync.Mutex
+ pending = map[string]chan WSMessage{}
+ extInfo map[string]any
+ lastBrowserOpenMu sync.Mutex
+ lastBrowserOpen time.Time
+)
+
+func isExtensionConnected() bool {
+ extMu.Lock()
+ defer extMu.Unlock()
+ return extConn != nil
+}
+
+func getExtensionInfo() map[string]any {
+ extMu.Lock()
+ defer extMu.Unlock()
+ if extInfo == nil {
+ return nil
+ }
+ out := map[string]any{}
+ for k, v := range extInfo {
+ out[k] = v
+ }
+ return out
+}
+
+func openBrowser(url string) error {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "windows":
+ cmd = exec.Command("cmd", "/c", "start", url)
+ case "darwin":
+ cmd = exec.Command("open", url)
+ default: // "linux", "freebsd", etc.
+ cmd = exec.Command("xdg-open", url)
+ }
+ return cmd.Start()
+}
+
+func wakeUpExtension() {
+ log.Println("[agent] Extension not connected. Waiting for background connection...")
+}
+
+func waitForExtension(timeout time.Duration) bool {
+ if isExtensionConnected() {
+ return true
+ }
+ wakeUpExtension()
+
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if isExtensionConnected() {
+ return true
+ }
+ time.Sleep(250 * time.Millisecond)
+ }
+ return false
+}
+
+func handleExtensionMessage(msg WSMessage) {
+ if msg.Result != nil {
+ if m, ok := msg.Result.(map[string]any); ok {
+ if raw, ok := m["rawText"].(string); ok {
+ if cfg.RawSSEOutputPath == "" {
+ return
+ }
+ err := os.WriteFile(cfg.RawSSEOutputPath, []byte(raw), 0644)
+ if err != nil {
+ log.Printf("ERROR WRITING RAW SSE: %v", err)
+ }
+ }
+ }
+ }
+
+ if msg.ID == "" {
+ if msg.Type == "extension_ready" {
+ extMu.Lock()
+ extInfo = msg.Params
+ extMu.Unlock()
+ log.Printf("[ws] extension ready: %v", msg.Params)
+ return
+ }
+ if msg.Type == "cookies_payload" {
+ // Extract cookies from params
+ if cookiesRaw, ok := msg.Params["cookies"]; ok {
+ raw, _ := json.Marshal(cookiesRaw)
+ var cookies []CookieObject
+ if err := json.Unmarshal(raw, &cookies); err == nil && len(cookies) > 0 {
+ HandleCookiePayload(cookies)
+ } else {
+ log.Printf("[ws] Failed to parse cookies_payload: %v", err)
+ }
+ }
+ return
+ }
+ if msg.Type == "sniffed_chat_request" {
+ sniff := sniffFromMessage(msg)
+ addSniff(sniff)
+ log.Printf("🔍 SNIFFED[%s/%s]: %s %s status=%d", sniff.Source, sniff.Phase, sniff.Method, sniff.URL, sniff.Status)
+ if len(sniff.Headers) > 0 {
+ log.Printf(" Headers:")
+ for k, v := range sniff.Headers {
+ log.Printf(" %s: %v", k, v)
+ }
+ }
+ if sniff.Payload != "" {
+ log.Printf(" Payload: %s", sniff.Payload)
+ }
+ if sniff.Response != "" {
+ log.Printf(" Response: %s", sniff.Response)
+ }
+ if sniff.Error != "" {
+ log.Printf(" Error: %s", sniff.Error)
+ }
+ }
+ return
+ }
+
+ pendingMu.Lock()
+ ch := pending[msg.ID]
+ pendingMu.Unlock()
+ if ch != nil {
+ if msg.Error != "" {
+ log.Printf("[ws] response id=%s error=%s", msg.ID[:8], msg.Error)
+ } else {
+ log.Printf("[ws] response id=%s ok", msg.ID[:8])
+ }
+ ch <- msg
+ return
+ }
+
+ b, _ := json.MarshalIndent(msg, "", " ")
+ log.Printf("[ws] unmatched message: %s", b)
+}
diff --git a/chatgpt-free-api/go.mod b/chatgpt-free-api/go.mod
new file mode 100644
index 0000000000000000000000000000000000000000..208e154390d65b073d1f9d5b85d12c5500aedd8a
--- /dev/null
+++ b/chatgpt-free-api/go.mod
@@ -0,0 +1,8 @@
+module chatgpt-agent
+
+go 1.26
+
+require (
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/websocket v1.5.3
+)
diff --git a/chatgpt-free-api/go.sum b/chatgpt-free-api/go.sum
new file mode 100644
index 0000000000000000000000000000000000000000..73bbf576b9cdd082ae92cf9567c0e9ee672621cd
--- /dev/null
+++ b/chatgpt-free-api/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
diff --git a/chatgpt-free-api/gpt-extension/background.js b/chatgpt-free-api/gpt-extension/background.js
new file mode 100644
index 0000000000000000000000000000000000000000..3c7ccf33ef2d2531277fbd2fae2151e28f1dd95a
--- /dev/null
+++ b/chatgpt-free-api/gpt-extension/background.js
@@ -0,0 +1,1030 @@
+/**
+ * GPT Agent Sync — Background Service Worker
+ * Auto-syncs ChatGPT cookies to Go backend and handles background API/PoW fallbacks.
+ */
+
+const AGENT_WS_URL = 'ws://127.0.0.1:9225';
+const CHATGPT_URL = 'https://chatgpt.com/';
+const CHATGPT_TAB_URLS = ['https://chatgpt.com/*', 'https://chat.openai.com/*'];
+
+let ws = null;
+let lastCookieSyncTime = null;
+let hasCookieSynced = false;
+let cookieSyncDebounceTimeout = null;
+let isRefreshingSession = false;
+let extensionState = 'off';
+
+// ChatGPT cookies to sync
+const CHATGPT_COOKIE_DOMAINS = ['.chatgpt.com', 'chatgpt.com', '.chat.openai.com', 'chat.openai.com'];
+const CHATGPT_COOKIE_NAMES = new Set([
+ '__Secure-next-auth.session-token',
+ '__Host-next-auth.csrf-token',
+ '__Secure-next-auth.callback-url',
+ 'oai-did',
+ '_puid',
+ '__cf_bm',
+ 'cf_clearance',
+ '_cfuvid',
+ 'oai-sc',
+ 'oai-hlib',
+ '__cflb',
+ 'intercom-id-dgkjq2bp',
+ 'intercom-device-id-dgkjq2bp',
+ 'intercom-session-dgkjq2bp',
+]);
+
+// Initialize alarms
+chrome.runtime.onInstalled.addListener(init);
+chrome.runtime.onStartup.addListener(init);
+
+chrome.alarms.onAlarm.addListener(async (alarm) => {
+ if (alarm.name === 'reconnect') connectToBackend();
+ if (alarm.name === 'keepAlive') keepAlive();
+ if (alarm.name === 'sessionKeepAlive') {
+ console.log('[ChatGPT Sync] Running periodic session keep-alive refresh...');
+ ensureChatGPTTabAndSync(true);
+ }
+});
+
+async function init() {
+ connectToBackend();
+ // Keep-alive ping every 24 seconds (no cookies payload)
+ chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
+ // Proactively refresh ChatGPT session tab every 15 minutes to rotate cookies
+ chrome.alarms.create('sessionKeepAlive', { periodInMinutes: 15 });
+
+ const data = await chrome.storage.local.get(['lastCookieSyncTime']);
+ if (data.lastCookieSyncTime) lastCookieSyncTime = data.lastCookieSyncTime;
+ setState('off');
+}
+
+// Badge and State Management
+function setState(newState) {
+ extensionState = newState;
+ const badges = { idle: '●', running: '▶', off: '○' };
+ const colors = { idle: '#10b981', running: '#f59e0b', off: '#ef4444' };
+
+ chrome.action.setBadgeText({ text: badges[extensionState] || '' });
+ chrome.action.setBadgeBackgroundColor({ color: colors[extensionState] || '#000' });
+
+ // Notify popup if it's open
+ chrome.runtime.sendMessage({ type: 'COOKIE_SYNC_UPDATE' }).catch(() => {});
+}
+
+// WebSocket Connection Management
+function connectToBackend() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ return;
+ }
+
+ console.log('[ChatGPT Sync] Connecting to local backend at:', AGENT_WS_URL);
+ hasCookieSynced = false;
+ setState('off');
+
+ try {
+ ws = new WebSocket(AGENT_WS_URL);
+ } catch (e) {
+ console.error('[ChatGPT Sync] WS Connection Error:', e);
+ scheduleReconnect();
+ return;
+ }
+
+ ws.onopen = () => {
+ console.log('[ChatGPT Sync] Connected to Go Backend!');
+ chrome.alarms.clear('reconnect');
+ setState('idle');
+ performCookieSync();
+ };
+
+ ws.onmessage = async (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.method === 'api_request') {
+ await handleApiRequest(msg);
+ } else if (msg.method === 'full_conversation') {
+ await handleFullConversation(msg);
+ } else if (msg.method === 'open_chatgpt') {
+ chrome.tabs.create({ url: CHATGPT_URL });
+ } else if (msg.method === 'solve_pow') {
+ await handleSolvePow(msg);
+ } else if (msg.method === 'solve_turnstile') {
+ await handleSolveTurnstile(msg);
+ } else if (msg.method === 'trigger_sync') {
+ console.log('[ChatGPT Sync] Backend requested fresh cookies. Activating session refresh...');
+ ensureChatGPTTabAndSync(false);
+ } else if (msg.method === 'reload_extension') {
+ chrome.runtime.reload();
+ }
+ } catch (e) {
+ console.error('[ChatGPT Sync] Error handling message:', e);
+ }
+ };
+
+ ws.onclose = () => {
+ console.log('[ChatGPT Sync] Connection closed. Reconnecting...');
+ setState('off');
+ scheduleReconnect();
+ };
+
+ ws.onerror = (err) => {
+ console.error('[ChatGPT Sync] WebSocket Error:', err);
+ setState('off');
+ };
+}
+
+function scheduleReconnect() {
+ chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s
+}
+
+function keepAlive() {
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'ping' }));
+ } else {
+ connectToBackend();
+ }
+}
+
+function sendToAgent(msg) {
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(msg));
+ }
+}
+
+// Cookie Sync Logic
+function performCookieSync() {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+
+ chrome.cookies.getAll({}, (cookies) => {
+ const chatgptCookies = cookies.filter(c => {
+ return CHATGPT_COOKIE_DOMAINS.some(d => c.domain === d || c.domain.endsWith(d));
+ });
+
+ const formatted = chatgptCookies.map(c => {
+ let exp = c.expirationDate;
+ if (!exp || c.session) {
+ exp = Math.floor(Date.now() / 1000) + 31536000; // Default 1 year
+ }
+ let sameSite = c.sameSite || 'unspecified';
+ if (sameSite === 'no_restriction') sameSite = 'none';
+
+ return {
+ domain: c.domain,
+ expirationDate: exp,
+ hostOnly: c.hostOnly,
+ httpOnly: c.httpOnly,
+ name: c.name,
+ path: c.path,
+ sameSite: sameSite,
+ secure: c.secure,
+ session: c.session,
+ storeId: c.storeId || '0',
+ value: c.value
+ };
+ });
+
+ console.log(`[ChatGPT Sync] Syncing ${formatted.length} cookies to backend`);
+ ws.send(JSON.stringify({
+ type: 'cookies_payload',
+ params: {
+ cookies: formatted
+ }
+ }));
+
+ hasCookieSynced = true;
+ lastCookieSyncTime = Date.now();
+ chrome.storage.local.set({ lastCookieSyncTime });
+ setState('idle');
+ });
+}
+
+// Proactive refresh mechanism: opens or reloads ChatGPT tab to rotate cookies
+async function ensureChatGPTTabAndSync(quietMode = false) {
+ if (isRefreshingSession) return;
+ isRefreshingSession = true;
+ setState('running');
+
+ try {
+ const tabs = await chrome.tabs.query({ url: CHATGPT_TAB_URLS });
+ if (tabs.length > 0) {
+ console.log('[ChatGPT Sync] ChatGPT tab exists. Reloading to rotate cookies...');
+ await chrome.tabs.reload(tabs[0].id);
+ } else {
+ console.log('[ChatGPT Sync] No ChatGPT tab found. Launching background session...');
+ await chrome.tabs.create({ url: CHATGPT_URL, active: false });
+ }
+
+ setTimeout(() => {
+ if (isRefreshingSession) {
+ isRefreshingSession = false;
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
+ }
+ }, 15000);
+ } catch (e) {
+ console.error('[ChatGPT Sync] Session refresh error:', e);
+ isRefreshingSession = false;
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
+ performCookieSync();
+ }
+}
+
+// Tab updates to trigger cookie sync on complete load
+chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ const isGptTab = tab.url && CHATGPT_TAB_URLS.some(pattern => {
+ const regex = new RegExp(pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'));
+ return regex.test(tab.url);
+ });
+ if (changeInfo.status === 'complete' && isGptTab) {
+ console.log('[ChatGPT Sync] ChatGPT tab loaded completely. Performing cookie sync...');
+ performCookieSync();
+ isRefreshingSession = false;
+ setState('idle');
+ }
+});
+
+// Real-Time Cookie Changed Listener
+chrome.cookies.onChanged.addListener((changeInfo) => {
+ const cookie = changeInfo.cookie;
+ const isTarget = CHATGPT_COOKIE_DOMAINS.some(d => cookie.domain === d || cookie.domain.endsWith(d));
+
+ if (isTarget && CHATGPT_COOKIE_NAMES.has(cookie.name)) {
+ if (changeInfo.removed) return;
+
+ console.log(`[ChatGPT Sync] Real-time cookie updated: ${cookie.name}. Scheduling sync...`);
+ if (cookieSyncDebounceTimeout) clearTimeout(cookieSyncDebounceTimeout);
+ cookieSyncDebounceTimeout = setTimeout(() => {
+ console.log('[ChatGPT Sync] Running debounced real-time cookie sync...');
+ performCookieSync();
+ }, 1500);
+ }
+});
+
+// Communication with Popup
+chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ if (msg.type === 'GET_STATUS') {
+ sendResponse({
+ connected: ws && ws.readyState === WebSocket.OPEN,
+ lastSyncTime: lastCookieSyncTime,
+ hasSyncedOnce: hasCookieSynced,
+ state: extensionState
+ });
+ }
+ if (msg.type === 'FORCE_SYNC') {
+ ensureChatGPTTabAndSync(false);
+ sendResponse({ ok: true });
+ }
+ return true;
+});
+
+// Metadata Helpers
+async function getOaiDeviceId() {
+ try {
+ const cookie = await chrome.cookies.get({ url: 'https://chatgpt.com', name: 'oai-did' });
+ return cookie ? cookie.value : '';
+ } catch (e) {
+ return '';
+ }
+}
+
+let cachedBuildNumber = null;
+let cachedBuildId = null;
+let lastCacheTime = 0;
+
+async function getClientMetadata() {
+ const now = Date.now();
+ if (cachedBuildNumber && cachedBuildId && (now - lastCacheTime < 3600000)) {
+ return { buildNumber: cachedBuildNumber, buildId: cachedBuildId };
+ }
+ try {
+ const resp = await fetch('https://chatgpt.com/', { credentials: 'omit' });
+ if (resp.ok) {
+ const text = await resp.text();
+ const buildNumberMatch = text.match(/meta name="build-number" content="([^"]+)"/);
+ const buildIdMatch = text.match(/"buildId":"([^"]+)"/);
+ if (buildNumberMatch) cachedBuildNumber = buildNumberMatch[1];
+ if (buildIdMatch) cachedBuildId = buildIdMatch[1];
+ lastCacheTime = now;
+ }
+ } catch (e) {
+ console.error('[ChatGPT Sync] Metadata fetch error:', e);
+ }
+ return {
+ buildNumber: cachedBuildNumber || 'main-build-latest',
+ buildId: cachedBuildId || 'latest'
+ };
+}
+
+async function getNativeHeaders() {
+ const headers = {};
+ const oaiDid = await getOaiDeviceId();
+ if (oaiDid) headers['OAI-Device-Id'] = oaiDid;
+ headers['OAI-Language'] = self.navigator.language || 'en-US';
+
+ const meta = await getClientMetadata();
+ if (meta.buildNumber) headers['OAI-Client-Build-Number'] = meta.buildNumber;
+ if (meta.buildId) headers['OAI-Client-Version'] = meta.buildId;
+ return headers;
+}
+
+// Background API request handlers
+async function handleApiRequest(msg) {
+ const id = msg.id || `api-${Date.now()}`;
+ const params = msg.params || {};
+ const { url, method = 'GET', headers = {}, body = null, returnHeaders = true, responseType = null } = params;
+
+ // Allow internal methods
+ if (url && url.startsWith('__internal__')) {
+ if (url.includes('solve_pow')) {
+ await handleSolvePow(msg);
+ return;
+ }
+ if (url.includes('solve_turnstile')) {
+ await handleSolveTurnstile(msg);
+ return;
+ }
+ sendToAgent({ id, error: 'UNKNOWN_INTERNAL_METHOD' });
+ return;
+ }
+
+ if (!url || !/^https:\/\/(chatgpt\.com|chat\.openai\.com|[a-z0-9]+\.oaiusercontent\.com)\//.test(url)) {
+ sendToAgent({ id, error: 'INVALID_URL: ' + (url || 'empty') });
+ return;
+ }
+
+ setState('running');
+
+ try {
+ const tab = await getChatGPTTab();
+ await waitForTabReady(tab.id);
+ const [exec] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ world: 'MAIN',
+ args: [{ url, method, headers, body, returnHeaders, responseType }],
+ func: async ({ url, method, headers, body, returnHeaders, responseType }) => {
+ try {
+ // Build headers — merge provided headers with native OAI headers
+ const nativeHeaders = {};
+ // Get OAI device ID from cookie
+ const cookies = document.cookie.split(';').map(c => c.trim());
+ const oaiDid = cookies.find(c => c.startsWith('oai-did='));
+ if (oaiDid) nativeHeaders['OAI-Device-Id'] = oaiDid.split('=')[1];
+ // Add standard OAI headers that ChatGPT frontend sends
+ nativeHeaders['OAI-Language'] = navigator.language || 'en-US';
+ // Try to get build number from page meta or global
+ try {
+ const buildMeta = document.querySelector('meta[name="build-number"]');
+ if (buildMeta) nativeHeaders['OAI-Client-Build-Number'] = buildMeta.content;
+ if (window.__NEXT_DATA__?.buildId) nativeHeaders['OAI-Client-Version'] = window.__NEXT_DATA__.buildId;
+ } catch(e) {}
+
+ const reqHeaders = { ...nativeHeaders, ...headers };
+ if (url.includes('/estuary/')) {
+ for (const key of Object.keys(reqHeaders)) {
+ if (key.toLowerCase().startsWith('oai-') || key.toLowerCase() === 'authorization') {
+ delete reqHeaders[key];
+ }
+ }
+ }
+ // Strip browser auth headers for external URLs (Azure blob storage)
+ if (url.includes('.oaiusercontent.com/')) {
+ for (const key of Object.keys(reqHeaders)) {
+ if (key.toLowerCase().startsWith('oai-') || key.toLowerCase() === 'authorization') {
+ delete reqHeaders[key];
+ }
+ }
+ }
+ const init = { method, headers: reqHeaders, credentials: url.includes('.oaiusercontent.com/') ? 'omit' : 'include' };
+ if (body !== null && body !== undefined && method !== 'GET' && method !== 'HEAD') {
+ // For binary blob uploads (Azure), convert base64 to Uint8Array
+ if (url.includes('.oaiusercontent.com/') && typeof body === 'string' && body.length > 1000) {
+ const binaryStr = atob(body);
+ const bytes = new Uint8Array(binaryStr.length);
+ for (let i = 0; i < binaryStr.length; i++) {
+ bytes[i] = binaryStr.charCodeAt(i);
+ }
+ init.body = bytes.buffer;
+ } else {
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
+ }
+ if (typeof body !== 'string' && !Object.keys(init.headers).some((k) => k.toLowerCase() === 'content-type')) {
+ init.headers['Content-Type'] = 'application/json';
+ }
+ }
+ const resp = await fetch(url, init);
+ const contentType = resp.headers.get('content-type') || '';
+ let respBody;
+ let isBase64 = false;
+
+ if (
+ responseType === 'base64' ||
+ contentType.startsWith('image/') ||
+ contentType.startsWith('audio/') ||
+ contentType.startsWith('video/') ||
+ contentType.startsWith('application/octet-stream')
+ ) {
+ const blob = await resp.blob();
+ respBody = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const parts = reader.result.split(',');
+ resolve(parts[1] || parts[0]);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ isBase64 = true;
+ } else {
+ respBody = await resp.text();
+ }
+
+ const respHeaders = {};
+ if (returnHeaders) {
+ resp.headers.forEach((v, k) => { respHeaders[k] = v; });
+ }
+ return { ok: true, status: resp.status, headers: respHeaders, body: respBody, isBase64, finalUrl: resp.url };
+ } catch (e) {
+ return { ok: false, error: String(e && e.message || e) };
+ }
+ },
+ });
+ const result = exec?.result;
+ if (!result || result.ok === false) {
+ sendToAgent({ id, error: result?.error || 'FETCH_FAILED' });
+ return;
+ }
+ sendToAgent({ id, result });
+ } catch (e) {
+ sendToAgent({ id, error: e.message || 'API_REQUEST_FAILED' });
+ } finally {
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
+ }
+}
+
+// PoW Solvers
+async function handleSolvePow(msg) {
+ const id = msg.id || `pow-${Date.now()}`;
+ const params = msg.params || {};
+ const { seed, difficulty } = params.body || params;
+ if (!seed || !difficulty) {
+ sendToAgent({ id, error: 'MISSING_SEED_OR_DIFFICULTY' });
+ return;
+ }
+ setState('running');
+ try {
+ const token = await solveShaPow(seed, difficulty);
+ sendToAgent({ id, result: { ok: true, status: 200, body: JSON.stringify({ token }), headers: {} } });
+ } catch (e) {
+ sendToAgent({ id, error: e.message || 'POW_SOLVE_FAILED' });
+ } finally {
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
+ }
+}
+
+async function handleSolveTurnstile(msg) {
+ const id = msg.id || `turnstile-${Date.now()}`;
+ sendToAgent({ id, error: 'TURNSTILE_SOLVE_NOT_SUPPORTED_IN_BACKGROUND' });
+}
+
+async function solveShaPow(seed, diff) {
+ const diffNum = parseInt(diff, 16) || parseInt(diff);
+ const prefix = '0'.repeat(Math.ceil(Math.log2(diffNum + 1) / 4));
+ const encoder = new TextEncoder();
+ for (let nonce = 0; nonce < 1000000; nonce++) {
+ const input = `${seed}${nonce}`;
+ const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(input));
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+ if (hashHex.startsWith(prefix)) {
+ return `gAAAAAB${btoa(input)}`;
+ }
+ }
+ return `gAAAAAB${btoa(seed + '0')}`;
+}
+
+async function getChatGPTTab() {
+ const tabs = await chrome.tabs.query({ url: CHATGPT_TAB_URLS });
+ if (tabs.length > 0) return tabs[0];
+ return await chrome.tabs.create({ url: CHATGPT_URL, active: false });
+}
+
+async function handleFullConversation(msg) {
+ const id = msg.id || `conv-${Date.now()}`;
+ const params = msg.params || {};
+ const { prompt, model = 'auto', conversation_id, thinking_effort, attachments } = params;
+ if (!prompt) {
+ sendToAgent({ id, error: 'MISSING_PROMPT' });
+ return;
+ }
+ setState('running');
+ try {
+ const tab = await getChatGPTTab();
+ const targetUrl = conversation_id ? `https://chatgpt.com/c/${conversation_id}` : CHATGPT_URL;
+ const isTargetConv = !!conversation_id;
+ const isTabConv = tab.url && tab.url.includes('/c/');
+ let needsNavigation = false;
+ if (isTargetConv) {
+ if (!tab.url || !tab.url.startsWith(targetUrl)) {
+ needsNavigation = true;
+ }
+ } else {
+ if (!tab.url || isTabConv || !tab.url.startsWith(CHATGPT_URL)) {
+ needsNavigation = true;
+ }
+ }
+
+ if (needsNavigation) {
+ console.log(`[ChatGPT Bridge] Navigating tab ${tab.id} to ${targetUrl}`);
+ await chrome.tabs.update(tab.id, { url: targetUrl });
+ await waitForTabReady(tab.id);
+ await sleep(2000); // Allow time for conversation DOM to load historical message IDs
+ } else {
+ await waitForTabReady(tab.id);
+ }
+ await sleep(250);
+ const [exec] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ world: 'MAIN',
+ args: [{ prompt, model, conversation_id, attachments }],
+ func: async ({ prompt, model, conversation_id, attachments }) => {
+ const previousBridgeFlag = window.__CHATGPT_BRIDGE_ACTIVE__;
+ window.__CHATGPT_BRIDGE_ACTIVE__ = true;
+ try {
+ // Step 1: Get access token from session
+ const sessResp = await fetch('/api/auth/session', { credentials: 'include' });
+ if (!sessResp.ok) return { ok: false, error: `session_http_${sessResp.status}` };
+ const sessData = await sessResp.json();
+ const accessToken = sessData.accessToken;
+ if (!accessToken) return { ok: false, error: 'NO_ACCESS_TOKEN' };
+
+ // Build native headers (Device-Id, Language, Build version)
+ const nativeHeaders = {};
+ const cookies = document.cookie.split(';').map(c => c.trim());
+ const oaiDid = cookies.find(c => c.startsWith('oai-did='));
+ if (oaiDid) nativeHeaders['OAI-Device-Id'] = oaiDid.split('=')[1];
+ nativeHeaders['OAI-Language'] = navigator.language || 'en-US';
+ try {
+ const buildMeta = document.querySelector('meta[name="build-number"]');
+ if (buildMeta) nativeHeaders['OAI-Client-Build-Number'] = buildMeta.content;
+ if (window.__NEXT_DATA__?.buildId) nativeHeaders['OAI-Client-Version'] = window.__NEXT_DATA__.buildId;
+ } catch(e) {}
+
+ const encodeConfig = (cfg) => btoa(unescape(encodeURIComponent(JSON.stringify(cfg))));
+
+ // Step 2: Build browser config for requirements/PoW tokens
+ const buildConfig = () => {
+ const d = new Date();
+ const dateStr = d.toString();
+ const getUserMedia = navigator.webkitGetUserMedia || navigator.getUserMedia;
+ const mediaSig = getUserMedia ? `webkitGetUserMedia−${String(getUserMedia)}` : `hardwareConcurrency−${navigator.hardwareConcurrency}`;
+
+ return [
+ screen.width + screen.height,
+ dateStr,
+ 4294705152,
+ 0,
+ navigator.userAgent,
+ null,
+ window.__NEXT_DATA__?.buildId || "",
+ navigator.language || "en-US",
+ (navigator.languages || [navigator.language]).join(','),
+ 0,
+ mediaSig,
+ 'location',
+ 'self',
+ performance.now() * 1000,
+ crypto.randomUUID(),
+ '',
+ navigator.hardwareConcurrency,
+ Date.now() - performance.now(),
+ ];
+ };
+
+ const generatePToken = () => {
+ const cfg = buildConfig();
+ const started = Date.now();
+ cfg[3] = 1;
+ cfg[9] = Date.now() - started;
+ return { token: 'gAAAAAC' + encodeConfig(cfg), config: cfg };
+ };
+
+ const generated = generatePToken();
+ const pToken = generated.token;
+
+ // Step 3: Get chat requirements
+ const reqHeaders = {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ ...nativeHeaders,
+ };
+ const reqResp = await fetch('/backend-api/sentinel/chat-requirements', {
+ method: 'POST',
+ headers: reqHeaders,
+ credentials: 'include',
+ body: JSON.stringify({ p: pToken }),
+ });
+ if (!reqResp.ok) return { ok: false, error: `requirements_http_${reqResp.status}` };
+ const reqData = await reqResp.json();
+ const chatToken = reqData.token;
+
+ // Step 4: Solve PoW if required
+ let proofToken = null;
+ if (reqData.proofofwork?.required) {
+ const seed = reqData.proofofwork.seed;
+ const difficulty = reqData.proofofwork.difficulty;
+ const fnvHash = (input) => {
+ let h = 2166136261 >>> 0;
+ for (let i = 0; i < input.length; i++) {
+ h ^= input.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ h ^= h >>> 16;
+ h = Math.imul(h, 2246822507) >>> 0;
+ h ^= h >>> 13;
+ h = Math.imul(h, 3266489909) >>> 0;
+ h ^= h >>> 16;
+ return (h >>> 0).toString(16).padStart(8, '0');
+ };
+ const powStarted = Date.now();
+ const config = generated.config.slice();
+ for (let nonce = 0; nonce < 500000; nonce++) {
+ config[3] = nonce;
+ config[9] = Date.now() - powStarted;
+ const encoded = encodeConfig(config);
+ if (fnvHash(seed + encoded).slice(0, difficulty.length) <= difficulty) {
+ proofToken = 'gAAAAAB' + encoded + '~S';
+ break;
+ }
+ }
+ if (!proofToken) {
+ return { ok: false, error: 'POW_SOLVE_FAILED' };
+ }
+ }
+
+ // Step 5: Solve turnstile if required
+ let turnstileToken = null;
+ if (reqData.turnstile?.required) {
+ if (typeof turnstile !== 'undefined') {
+ try { turnstileToken = turnstile.getResponse(); } catch(e) {}
+ }
+ if (!turnstileToken) {
+ const el = document.querySelector('[name="cf-turnstile-response"]');
+ if (el) turnstileToken = el.value;
+ }
+ // If still no token, try to render a new turnstile
+ if (!turnstileToken && typeof turnstile !== 'undefined' && reqData.turnstile.dx) {
+ try {
+ const container = document.createElement('div');
+ container.style.display = 'none';
+ document.body.appendChild(container);
+ await new Promise((resolve) => {
+ turnstile.render(container, {
+ sitekey: reqData.turnstile.sitekey || '0x4AAAAAAAx1CyDNL8zOEPe7',
+ callback: (token) => { turnstileToken = token; resolve(); },
+ 'error-callback': () => resolve(),
+ timeout: 10000,
+ });
+ setTimeout(resolve, 15000);
+ });
+ container.remove();
+ } catch(e) {}
+ }
+ }
+
+ // Resolve target conversation ID
+ let resolvedConversationId = conversation_id || null;
+
+ // Resolve parent message ID
+ let parentId = 'client-created-root';
+ if (resolvedConversationId) {
+ try {
+ const convUrl = `/backend-api/conversation/${resolvedConversationId}`;
+ const convDetailResp = await fetch(convUrl, {
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ ...nativeHeaders,
+ },
+ credentials: 'include',
+ });
+ if (convDetailResp.ok) {
+ const convData = await convDetailResp.json();
+ if (convData.current_node) {
+ parentId = convData.current_node;
+ console.log(`[ChatGPT Bridge] Resolved parent message ID from API: ${parentId}`);
+ }
+ }
+ } catch (e) {
+ console.error('[ChatGPT Bridge] Failed to fetch conversation detail:', e);
+ }
+
+ if (parentId === 'client-created-root') {
+ const msgEls = Array.from(document.querySelectorAll('[data-message-id]'));
+ if (msgEls.length > 0) {
+ const lastId = msgEls[msgEls.length - 1].getAttribute('data-message-id');
+ if (lastId) {
+ parentId = lastId;
+ console.log(`[ChatGPT Bridge] Resolved parent message ID from DOM: ${parentId}`);
+ }
+ }
+ }
+ }
+
+ return {
+ ok: true,
+ accessToken,
+ nativeHeaders,
+ chatToken,
+ proofToken,
+ turnstileToken,
+ parentId,
+ resolvedConversationId
+ };
+ } catch (e) {
+ return { ok: false, error: e.message || 'UNKNOWN_ERROR' };
+ } finally {
+ window.__CHATGPT_BRIDGE_ACTIVE__ = previousBridgeFlag;
+ }
+ },
+ });
+ const prep = exec?.result;
+ if (!prep || !prep.ok) {
+ sendToAgent({ id, error: prep?.error || 'CONVERSATION_PREPARATION_FAILED', result: prep });
+ return;
+ }
+
+ const echoStart = Math.max(1000, Math.floor(performance.now()));
+ const echoEnd = echoStart + 1000 + Math.floor(Math.random() * 500);
+ const convHeaders = {
+ 'Authorization': `Bearer ${prep.accessToken}`,
+ 'Content-Type': 'application/json',
+ 'Accept': 'text/event-stream',
+ 'openai-sentinel-chat-requirements-token': prep.chatToken,
+ 'oai-echo-logs': `0,${echoStart},1,${echoEnd}`,
+ ...prep.nativeHeaders,
+ };
+ if (prep.proofToken) convHeaders['openai-sentinel-proof-token'] = prep.proofToken;
+ if (prep.turnstileToken) convHeaders['openai-sentinel-turnstile-token'] = prep.turnstileToken;
+
+ let actualModel = model;
+ let resolvedThinkingEffort = thinking_effort || null;
+ if (model && model.includes('thinking')) {
+ if (model.includes('-extended')) {
+ resolvedThinkingEffort = 'extended';
+ actualModel = model.replace('-extended', '');
+ } else if (model.includes('-standard')) {
+ resolvedThinkingEffort = 'standard';
+ actualModel = model.replace('-standard', '');
+ } else if (!resolvedThinkingEffort) {
+ resolvedThinkingEffort = 'standard';
+ }
+ }
+
+ const commonConversationFields = {
+ action: 'next',
+ model: actualModel,
+ timezone_offset_min: new Date().getTimezoneOffset(),
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ history_and_training_disabled: false,
+ fork_from_shared_post: false,
+ force_paragen: false,
+ force_rate_limit: false,
+ conversation_mode: { kind: 'primary_assistant' },
+ enable_message_followups: true,
+ system_hints: [],
+ supports_buffering: true,
+ supported_encodings: ['v1'],
+ paragen_cot_summary_display_override: 'allow',
+ force_parallel_switch: 'auto',
+ };
+ if (resolvedThinkingEffort) {
+ commonConversationFields.thinking_effort = resolvedThinkingEffort;
+ }
+
+ const prepareBody = {
+ ...commonConversationFields,
+ parent_message_id: prep.parentId,
+ };
+ if (prep.resolvedConversationId) {
+ prepareBody.conversation_id = prep.resolvedConversationId;
+ }
+
+ // Build message content - support multimodal (text + image attachments)
+ const messageParts = [prompt];
+ const messageAttachments = [];
+ if (attachments && attachments.length > 0) {
+ for (const att of attachments) {
+ messageAttachments.push({
+ id: att.id,
+ name: att.name || 'image.png',
+ size: att.size || 0,
+ mime_type: att.mime_type || 'image/png',
+ width: att.width || null,
+ height: att.height || null,
+ });
+ }
+ }
+
+ const messageContent = attachments && attachments.length > 0
+ ? { content_type: 'multimodal_text', parts: messageParts }
+ : { content_type: 'text', parts: [prompt] };
+
+ const userMessage = {
+ id: crypto.randomUUID(),
+ author: { role: 'user' },
+ content: messageContent,
+ metadata: {
+ selected_github_repos: [],
+ selected_all_github_repos: false,
+ serialization_metadata: { custom_symbol_offsets: [] },
+ },
+ create_time: Math.round(Date.now()) / 1000,
+ };
+ if (messageAttachments.length > 0) {
+ userMessage.metadata.attachments = messageAttachments;
+ }
+
+ const convBody = {
+ ...commonConversationFields,
+ messages: [userMessage],
+ parent_message_id: prep.parentId,
+ websocket_request_id: crypto.randomUUID(),
+ client_contextual_info: {
+ is_dark_mode: false,
+ time_since_loaded: 0,
+ page_height: 800,
+ page_width: 1200,
+ pixel_ratio: 1,
+ screen_height: 1080,
+ screen_width: 1920,
+ },
+ };
+ if (prep.resolvedConversationId) {
+ convBody.conversation_id = prep.resolvedConversationId;
+ }
+
+ const prepareHeaders = {
+ ...convHeaders,
+ 'Accept': 'application/json',
+ 'x-conduit-token': 'no-token',
+ };
+ const prepareResp = await fetch('https://chatgpt.com/backend-api/f/conversation/prepare', {
+ method: 'POST',
+ headers: prepareHeaders,
+ credentials: 'include',
+ body: JSON.stringify(prepareBody),
+ });
+ if (!prepareResp.ok) {
+ const errText = await prepareResp.text();
+ sendToAgent({ id, error: `prepare_http_${prepareResp.status}: ${errText.slice(0, 500)}` });
+ return;
+ }
+ const prepareText = await prepareResp.text();
+ let conduitToken = 'no-token';
+ try {
+ const prepared = JSON.parse(prepareText);
+ conduitToken = prepared.conduit_token || prepared.conduitToken || prepared.token || conduitToken;
+ } catch(e) {}
+
+ const finalHeaders = {
+ ...convHeaders,
+ 'x-conduit-token': conduitToken,
+ };
+ const convResp = await fetch('https://chatgpt.com/backend-api/f/conversation', {
+ method: 'POST',
+ headers: finalHeaders,
+ credentials: 'include',
+ body: JSON.stringify(convBody),
+ });
+
+ if (!convResp.ok) {
+ const errText = await convResp.text();
+ sendToAgent({ id, error: `conversation_http_${convResp.status}: ${errText.slice(0, 500)}` });
+ return;
+ }
+
+ const text = await convResp.text();
+ let convId = '';
+ const parseSSEFinal = (raw) => {
+ let snapshot = '';
+ let delta = '';
+ let fileIds = new Set();
+ let currentContentType = '';
+ let currentRole = '';
+ const extractParts0 = (obj) => {
+ const role = obj?.message?.author?.role;
+ if (role !== 'assistant' && role !== 'tool') return '';
+ const parts = obj?.message?.content?.parts;
+ if (!Array.isArray(parts)) return '';
+ for (const part of parts) {
+ if (part && typeof part === 'object' && part.content_type === 'image_asset_pointer' && part.asset_pointer) {
+ const match = part.asset_pointer.match(/(?:file-service|sediment):\/\/(file[_-][\w-]+)/);
+ if (match && match[1]) {
+ const fileId = match[1];
+ const isAttachment = attachments && attachments.some(att => att.id === fileId);
+ if (!isAttachment) {
+ fileIds.add(fileId);
+ }
+ }
+ }
+ }
+ return typeof parts[0] === 'string' ? parts[0] : '';
+ };
+
+ const processDeltaObj = (ev) => {
+ if (
+ typeof ev.p === 'string' &&
+ ev.p.includes('/message/content/parts/0')
+ ) {
+ if (ev.o === 'replace' && typeof ev.v === 'string') {
+ delta = ev.v;
+ } else if ((ev.o === undefined || ev.o === 'append') && typeof ev.v === 'string') {
+ delta += ev.v;
+ }
+ } else if (ev.p === undefined && typeof ev.v === 'string') {
+ if (currentContentType === 'text' || currentContentType === 'multimodal_text' || currentContentType === '') {
+ delta += ev.v;
+ }
+ } else if (ev.o === 'patch' && Array.isArray(ev.v)) {
+ for (const sub of ev.v) {
+ processDeltaObj(sub);
+ }
+ }
+ };
+
+ const lines = raw.split('\n');
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith('data:')) continue;
+ const data = trimmed.slice(5).trim();
+ if (!data || data === '[DONE]') continue;
+ try {
+ const parsed = JSON.parse(data);
+ if (parsed.conversation_id) {
+ convId = parsed.conversation_id;
+ }
+ if (parsed.v && typeof parsed.v === 'object') {
+ if (parsed.v.conversation_id) {
+ convId = parsed.v.conversation_id;
+ }
+ if (parsed.v.message) {
+ currentContentType = parsed.v.message.content?.content_type || '';
+ currentRole = parsed.v.message.author?.role || '';
+ }
+ } else if (parsed.message) {
+ currentContentType = parsed.message.content?.content_type || '';
+ currentRole = parsed.message.author?.role || '';
+ }
+ const direct = extractParts0(parsed);
+ if (direct) snapshot = direct;
+ if (parsed.v && typeof parsed.v === 'object') {
+ const nested = extractParts0(parsed.v);
+ if (nested) snapshot = nested;
+ }
+ processDeltaObj(parsed);
+ } catch(e) {}
+ }
+ let out = snapshot;
+ if (delta) {
+ if (delta.length > snapshot.length) {
+ out = delta;
+ } else if (snapshot && !snapshot.includes(delta)) {
+ out = snapshot + delta;
+ }
+ }
+ if (fileIds.size > 0) {
+ let imageMarkdown = '\n\n';
+ for (const fileId of fileIds) {
+ imageMarkdown += `})\n`;
+ }
+ out += imageMarkdown;
+ }
+ return out;
+ };
+ const assistantText = parseSSEFinal(text);
+ if (!assistantText) {
+ sendToAgent({
+ id,
+ error: `EMPTY_ASSISTANT_RESPONSE raw_len=${text.length} raw=${text.replace(/\s+/g, ' ').slice(0, 500)}`,
+ result: { rawText: text }
+ });
+ return;
+ }
+
+ sendToAgent({ id, result: { ok: true, status: 200, body: assistantText, rawText: text, conversation_id: convId || prep.resolvedConversationId, headers: {} } });
+ } catch (e) {
+ sendToAgent({ id, error: e.message || 'FULL_CONVERSATION_FAILED' });
+ } finally {
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
+ }
+}
+
+async function waitForTabReady(tabId, timeout = 30000) {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ const tab = await chrome.tabs.get(tabId);
+ if (tab.status === 'complete') return;
+ await sleep(300);
+ }
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/chatgpt-free-api/gpt-extension/icon128.png b/chatgpt-free-api/gpt-extension/icon128.png
new file mode 100644
index 0000000000000000000000000000000000000000..c30770cc24ca1378fdc3bc2fd08c8f3d97bd12b9
Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon128.png differ
diff --git a/chatgpt-free-api/gpt-extension/icon16.png b/chatgpt-free-api/gpt-extension/icon16.png
new file mode 100644
index 0000000000000000000000000000000000000000..d6c4d0fe7e66cea16622dd3f5a137a40b1c9ce7d
Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon16.png differ
diff --git a/chatgpt-free-api/gpt-extension/icon48.png b/chatgpt-free-api/gpt-extension/icon48.png
new file mode 100644
index 0000000000000000000000000000000000000000..d3bc805499811238ce8b7e9383a4f07280427f70
Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon48.png differ
diff --git a/chatgpt-free-api/gpt-extension/icon_large.png b/chatgpt-free-api/gpt-extension/icon_large.png
new file mode 100644
index 0000000000000000000000000000000000000000..068f7f9755e950f25c47aed8fce1a5e09cdd7e78
Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon_large.png differ
diff --git a/chatgpt-free-api/gpt-extension/manifest.json b/chatgpt-free-api/gpt-extension/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..ccf6000e5e2a573a9c10a4693f08bfc24e97304b
--- /dev/null
+++ b/chatgpt-free-api/gpt-extension/manifest.json
@@ -0,0 +1,28 @@
+{
+ "manifest_version": 3,
+ "name": "Gpt Agent Api",
+ "version": "1.0.0",
+ "description": "Auto-syncs ChatGPT cookies to your local ChatGPT Free API server",
+ "icons": {
+ "16": "icon16.png",
+ "48": "icon48.png",
+ "128": "icon128.png"
+ },
+ "permissions": ["cookies", "storage", "alarms", "tabs", "scripting"],
+ "host_permissions": [
+ "https://chatgpt.com/*",
+ "https://chat.openai.com/*"
+ ],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "action": {
+ "default_popup": "popup.html",
+ "default_title": "GPT Agent Sync",
+ "default_icon": {
+ "16": "icon16.png",
+ "48": "icon48.png",
+ "128": "icon128.png"
+ }
+ }
+}
diff --git a/chatgpt-free-api/gpt-extension/popup.html b/chatgpt-free-api/gpt-extension/popup.html
new file mode 100644
index 0000000000000000000000000000000000000000..f4a8ae5b67c8d90b02a51f8e6edc15aa2c702410
--- /dev/null
+++ b/chatgpt-free-api/gpt-extension/popup.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
Gpt Agent Api
+
+
+
+
+ Server Connection
+
+
+ Disconnected
+
+
+
+ Last Sync
+ Never
+
+
+
+
+
+
+
+
diff --git a/chatgpt-free-api/gpt-extension/popup.js b/chatgpt-free-api/gpt-extension/popup.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ffb9865c4139041b15f0557295d69001388c25e
--- /dev/null
+++ b/chatgpt-free-api/gpt-extension/popup.js
@@ -0,0 +1,56 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const statusBadge = document.getElementById('status-badge');
+ const statusText = document.getElementById('status-text');
+ const lastSync = document.getElementById('last-sync');
+ const syncBtn = document.getElementById('sync-btn');
+
+ function updateUI() {
+ chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => {
+ if (chrome.runtime.lastError) return;
+ if (!response) return;
+
+ if (response.state === 'running') {
+ statusBadge.className = 'badge running';
+ statusText.innerText = 'Active';
+ statusText.style.color = '#f59e0b';
+ } else if (response.connected) {
+ statusBadge.className = 'badge connected';
+ statusText.innerText = 'Connected';
+ statusText.style.color = '#10b981';
+ } else {
+ statusBadge.className = 'badge disconnected';
+ statusText.innerText = 'Disconnected';
+ statusText.style.color = '#ef4444';
+ }
+
+ if (response.lastSyncTime) {
+ const date = new Date(response.lastSyncTime);
+ lastSync.innerText = date.toLocaleTimeString();
+ } else {
+ lastSync.innerText = 'Never';
+ }
+ });
+ }
+
+ // Initial update
+ updateUI();
+
+ // Listen for real-time updates from background worker
+ chrome.runtime.onMessage.addListener((msg) => {
+ if (msg.type === 'COOKIE_SYNC_UPDATE') {
+ updateUI();
+ }
+ });
+
+ syncBtn.addEventListener('click', () => {
+ syncBtn.disabled = true;
+ syncBtn.innerText = 'Syncing...';
+ chrome.runtime.sendMessage({ type: 'FORCE_SYNC' }, () => {
+ setTimeout(() => {
+ syncBtn.disabled = false;
+ syncBtn.innerText = 'Force Sync Cookies';
+ updateUI();
+ }, 800);
+ });
+ });
+});
diff --git a/chatgpt-free-api/handlers.go b/chatgpt-free-api/handlers.go
new file mode 100644
index 0000000000000000000000000000000000000000..5bca49c658819860d0373e19ce788aef966e1948
--- /dev/null
+++ b/chatgpt-free-api/handlers.go
@@ -0,0 +1,483 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+)
+
+type ChatRequest struct {
+ Prompt string `json:"prompt"`
+ WaitForResponse bool `json:"wait_for_response"`
+ ConversationID string `json:"conversation_id"`
+ Model string `json:"model"`
+ ThinkingEffort string `json:"thinking_effort"`
+}
+
+type BulkChatRequest struct {
+ Prompts []string `json:"prompts"`
+ WaitForResponse bool `json:"wait_for_response"`
+ DelaySeconds int `json:"delay_seconds"`
+}
+
+type ChatResult struct {
+ Prompt string `json:"prompt"`
+ Response string `json:"response,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type OpenAIChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type OpenAIChatCompletionRequest struct {
+ Model string `json:"model"`
+ Messages []OpenAIChatMessage `json:"messages"`
+ Stream bool `json:"stream"`
+}
+
+type OpenAIChoice struct {
+ Index int `json:"index"`
+ Message OpenAIChatMessage `json:"message"`
+ FinishReason string `json:"finish_reason"`
+}
+
+type OpenAIChatCompletionResponse struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []OpenAIChoice `json:"choices"`
+}
+
+var testUpgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
+
+func handleWS(w http.ResponseWriter, r *http.Request) {
+ conn, err := testUpgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("WS upgrade error: %v", err)
+ return
+ }
+
+ extMu.Lock()
+ extConn = conn
+ if !reloadTriggered {
+ reloadTriggered = true
+ log.Println("[ws] Sending reload_extension request to sync extension files from disk...")
+ _ = conn.WriteJSON(WSMessage{
+ Method: "reload_extension",
+ })
+ }
+ extMu.Unlock()
+ log.Println("[ws] Extension connected from", r.RemoteAddr)
+
+ defer func() {
+ extMu.Lock()
+ if extConn == conn {
+ extConn = nil
+ }
+ extMu.Unlock()
+ conn.Close()
+ log.Println("Extension disconnected")
+
+ // Fail all pending channels
+ pendingMu.Lock()
+ for id, ch := range pending {
+ ch <- WSMessage{
+ ID: id,
+ Error: "extension disconnected",
+ }
+ }
+ pending = map[string]chan WSMessage{}
+ pendingMu.Unlock()
+ }()
+
+ for {
+ var msg WSMessage
+ if err := conn.ReadJSON(&msg); err != nil {
+ log.Printf("[ws] read closed: %v", err)
+ return
+ }
+ if msg.Type != "" {
+ log.Printf("[ws] event: type=%s", msg.Type)
+ }
+ handleExtensionMessage(msg)
+ }
+}
+
+func handleHealth(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, map[string]any{"ok": true, "mode": "chatgpt-api-bridge", "extensionConnected": isExtensionConnected(), "extension": getExtensionInfo()})
+}
+
+func handleCallback(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var msg WSMessage
+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ handleExtensionMessage(msg)
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func handleChat(w http.ResponseWriter, r *http.Request) {
+ req := ChatRequest{WaitForResponse: true}
+ if r.Method == http.MethodGet {
+ req.Prompt = r.URL.Query().Get("prompt")
+ req.ConversationID = r.URL.Query().Get("conversation_id")
+ req.Model = r.URL.Query().Get("model")
+ req.ThinkingEffort = r.URL.Query().Get("thinking_effort")
+ } else if r.Method == http.MethodPost {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ } else {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if req.Prompt == "" {
+ http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
+ return
+ }
+ if req.Model == "" {
+ req.Model = cfg.DefaultModel
+ }
+
+ if req.ConversationID == "new" {
+ req.ConversationID = ""
+ clearActiveConversationID()
+ }
+
+ log.Printf("[chat] sending prompt via API mode (model=%s, thinking_effort=%s, conversation_id=%s): %s", req.Model, req.ThinkingEffort, req.ConversationID, snippet(req.Prompt, 50))
+ response, newConvID, err := sendChatWithConversation(req.Prompt, req.ConversationID, req.Model, req.ThinkingEffort)
+ if err != nil {
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+
+ if newConvID != "" {
+ setActiveConversationID(newConvID)
+ }
+
+ // Scan the response for download links to include in the JSON
+ var images []string
+ var fileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
+ matches := fileIDRegexp.FindAllString(response, -1)
+ if len(matches) > 0 {
+ seen := make(map[string]bool)
+ for _, id := range matches {
+ if !seen[id] {
+ seen[id] = true
+ name := getPromptFilename(req.Prompt, id)
+ images = append(images, "output/"+name+".png")
+ }
+ }
+ }
+
+ writeJSON(w, map[string]any{
+ "ok": true,
+ "response": response,
+ "conversation_id": newConvID,
+ "images": images,
+ })
+}
+
+func handleChatBulk(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ req := BulkChatRequest{WaitForResponse: true, DelaySeconds: cfg.BulkDelaySeconds}
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if len(req.Prompts) == 0 {
+ http.Error(w, `{"error":"prompts required"}`, http.StatusBadRequest)
+ return
+ }
+ results := make([]ChatResult, 0, len(req.Prompts))
+ for i, prompt := range req.Prompts {
+ text, _, err := sendChat(prompt)
+ item := ChatResult{Prompt: prompt, Response: text}
+ if err != nil {
+ item.Error = err.Error()
+ }
+ results = append(results, item)
+ if i < len(req.Prompts)-1 && req.DelaySeconds > 0 {
+ time.Sleep(time.Duration(req.DelaySeconds) * time.Second)
+ }
+ }
+ writeJSON(w, map[string]any{"ok": true, "count": len(results), "results": results})
+}
+
+func handleChatEdit(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse multipart form (max 32MB)
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ http.Error(w, `{"error":"invalid multipart form: `+err.Error()+`"}`, http.StatusBadRequest)
+ return
+ }
+
+ prompt := r.FormValue("prompt")
+ if prompt == "" {
+ http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
+ return
+ }
+
+ conversationID := r.FormValue("conversation_id")
+ model := r.FormValue("model")
+ if model == "" {
+ model = cfg.DefaultModel
+ }
+
+ if conversationID == "new" {
+ conversationID = ""
+ clearActiveConversationID()
+ }
+
+ var attachments []FileAttachment
+
+ // Handle image upload if present
+ file, header, err := r.FormFile("image")
+ if err == nil {
+ defer file.Close()
+
+ // Save to temp file
+ _ = os.MkdirAll("output/tmp", 0755)
+ defer os.Remove("output/tmp")
+ tmpPath := filepath.Join("output", "tmp", header.Filename)
+ out, err := os.Create(tmpPath)
+ if err != nil {
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "save temp file: " + err.Error()})
+ return
+ }
+ if _, err := io.Copy(out, file); err != nil {
+ out.Close()
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "copy file: " + err.Error()})
+ return
+ }
+ out.Close()
+ defer os.Remove(tmpPath)
+
+ log.Printf("[chat-edit] Uploading image %s to ChatGPT...", header.Filename)
+ att, err := uploadFileToChatGPT(tmpPath)
+ if err != nil {
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "upload image: " + err.Error()})
+ return
+ }
+ attachments = append(attachments, *att)
+ log.Printf("[chat-edit] Image uploaded: %s (file_id: %s)", header.Filename, att.ID)
+ }
+
+ log.Printf("[chat-edit] sending prompt with %d attachment(s): %s", len(attachments), snippet(prompt, 50))
+
+ response, newConvID, err := sendChatWithConversationAndAttachments(prompt, conversationID, model, cfg.DefaultThinkingEffort, attachments)
+ if err != nil {
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+
+ if newConvID != "" {
+ setActiveConversationID(newConvID)
+ }
+
+ // Scan response for image downloads
+ var images []string
+ var editFileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
+ matches := editFileIDRegexp.FindAllString(response, -1)
+ if len(matches) > 0 {
+ uploadedIDs := make(map[string]bool)
+ for _, att := range attachments {
+ uploadedIDs[att.ID] = true
+ }
+ seen := make(map[string]bool)
+ for _, id := range matches {
+ if !seen[id] && !uploadedIDs[id] {
+ seen[id] = true
+ name := getPromptFilename(prompt, id)
+ images = append(images, "output/"+name+".png")
+ }
+ }
+ }
+
+ writeJSON(w, map[string]any{
+ "ok": true,
+ "response": response,
+ "conversation_id": newConvID,
+ "images": images,
+ })
+}
+
+func handleSniffs(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ sniffMu.Lock()
+ out := append([]SniffedRequest(nil), sniffs...)
+ sniffMu.Unlock()
+ writeJSON(w, map[string]any{"ok": true, "count": len(out), "sniffs": out})
+ case http.MethodDelete:
+ sniffMu.Lock()
+ sniffs = nil
+ sniffMu.Unlock()
+ writeJSON(w, map[string]any{"ok": true, "cleared": true})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func handleOpenAIChat(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req OpenAIChatCompletionRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if len(req.Messages) == 0 {
+ http.Error(w, `{"error":{"message":"messages array is empty"}}`, http.StatusBadRequest)
+ return
+ }
+
+ // Extract prompt from messages (last user message)
+ var prompt string
+ for i := len(req.Messages) - 1; i >= 0; i-- {
+ if req.Messages[i].Role == "user" {
+ prompt = req.Messages[i].Content
+ break
+ }
+ }
+ if prompt == "" {
+ prompt = req.Messages[len(req.Messages)-1].Content
+ }
+
+ model := req.Model
+ if model == "" {
+ model = cfg.DefaultModel
+ }
+
+ conversationID := getActiveConversationID()
+
+ log.Printf("[openai-chat] received OpenAI request (model=%s, conversation_id=%s), sending prompt: %s", model, conversationID, snippet(prompt, 50))
+ response, newConvID, err := sendChatWithConversation(prompt, conversationID, model, cfg.DefaultThinkingEffort)
+ if err != nil {
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{
+ "error": map[string]any{
+ "message": err.Error(),
+ "type": "api_error",
+ },
+ })
+ return
+ }
+
+ if newConvID != "" {
+ setActiveConversationID(newConvID)
+ }
+
+ resp := OpenAIChatCompletionResponse{
+ ID: "chatcmpl-" + uuid.NewString()[:12],
+ Object: "chat.completion",
+ Created: time.Now().Unix(),
+ Model: model,
+ Choices: []OpenAIChoice{
+ {
+ Index: 0,
+ Message: OpenAIChatMessage{
+ Role: "assistant",
+ Content: response,
+ },
+ FinishReason: "stop",
+ },
+ },
+ }
+
+ writeJSON(w, resp)
+}
+
+func handleDownload(w http.ResponseWriter, r *http.Request) {
+ fileID := r.URL.Query().Get("file_id")
+ if fileID == "" {
+ http.Error(w, `{"error":"file_id required"}`, http.StatusBadRequest)
+ return
+ }
+
+ // 1. Determine local path based on prompt mapping
+ prompt := getFilePrompt(fileID)
+ // Also check if prompt is passed in query string as backup
+ if prompt == "" {
+ prompt = r.URL.Query().Get("prompt")
+ }
+
+ var name string
+ var localPath string
+ if prompt != "" {
+ name = getPromptFilename(prompt, fileID)
+ localPath = fmt.Sprintf("output/%s.png", name)
+ } else {
+ localPath = fmt.Sprintf("output/%s.png", fileID)
+ }
+
+ var data []byte
+ var err error
+
+ // 2. Check if file already exists locally
+ if _, err = os.Stat(localPath); err == nil {
+ log.Printf("[download] File %s already exists locally, serving from disk", localPath)
+ data, err = os.ReadFile(localPath)
+ if err != nil {
+ log.Printf("[download] Error reading local file %s: %v, will redownload", localPath, err)
+ data = nil
+ }
+ }
+
+ // 3. If not found or failed to read, download it
+ if len(data) == 0 {
+ data, err = downloadChatGPTFile(fileID)
+ if err != nil {
+ log.Printf("[download] error downloading file %s: %v", fileID, err)
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+
+ // Save file locally to output folder
+ _ = os.MkdirAll("output", 0755)
+ err = os.WriteFile(localPath, data, 0644)
+ if err != nil {
+ log.Printf("[download] error saving file locally to %s: %v", localPath, err)
+ } else {
+ log.Printf("[download] file saved successfully to %s", localPath)
+ }
+ }
+
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
+
+ headerFilename := fileID + ".png"
+ if name != "" {
+ headerFilename = name + ".png"
+ }
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", headerFilename))
+ w.Write(data)
+}
diff --git a/chatgpt-free-api/helpers.go b/chatgpt-free-api/helpers.go
new file mode 100644
index 0000000000000000000000000000000000000000..c105b91a033562df19fecffe38c8f1fde7cd0347
--- /dev/null
+++ b/chatgpt-free-api/helpers.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+)
+
+func intFromAny(v any) int {
+ switch x := v.(type) {
+ case int:
+ return x
+ case float64:
+ return int(x)
+ case json.Number:
+ i, _ := x.Int64()
+ return int(i)
+ default:
+ return 0
+ }
+}
+
+func boolFromAny(v any) (bool, bool) {
+ switch x := v.(type) {
+ case bool:
+ return x, true
+ default:
+ return false, false
+ }
+}
+
+func redactHeaderValue(key string, value any) any {
+ switch strings.ToLower(key) {
+ case "authorization", "openai-sentinel-chat-requirements-token", "openai-sentinel-proof-token", "openai-sentinel-turnstile-token":
+ return "[redacted]"
+ default:
+ return value
+ }
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ writeJSONStatus(w, http.StatusOK, v)
+}
+
+func writeJSONStatus(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(v)
+}
diff --git a/chatgpt-free-api/main.go b/chatgpt-free-api/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..52cefc883d1ce4d9fa8d808108863b530a86cb67
--- /dev/null
+++ b/chatgpt-free-api/main.go
@@ -0,0 +1,5 @@
+package main
+
+func main() {
+ Start()
+}
diff --git a/chatgpt-free-api/sniff.go b/chatgpt-free-api/sniff.go
new file mode 100644
index 0000000000000000000000000000000000000000..7aed70e5bfb12d45517b049c9900ed9cc3f5dd3f
--- /dev/null
+++ b/chatgpt-free-api/sniff.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "sync"
+ "time"
+)
+
+type SniffedRequest struct {
+ Time string `json:"time"`
+ Source string `json:"source,omitempty"`
+ Phase string `json:"phase,omitempty"`
+ Method string `json:"method,omitempty"`
+ URL string `json:"url,omitempty"`
+ Status int `json:"status,omitempty"`
+ OK *bool `json:"ok,omitempty"`
+ Headers map[string]any `json:"headers,omitempty"`
+ Payload string `json:"payload,omitempty"`
+ Response string `json:"response,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+var (
+ sniffMu sync.Mutex
+ sniffs []SniffedRequest
+)
+
+func sniffFromMessage(msg WSMessage) SniffedRequest {
+ source, _ := msg.Params["source"].(string)
+ phase, _ := msg.Params["phase"].(string)
+ url, _ := msg.Params["url"].(string)
+ payload, _ := msg.Params["payload"].(string)
+ response, _ := msg.Params["response"].(string)
+ errText, _ := msg.Params["error"].(string)
+ status := intFromAny(msg.Params["status"])
+ headers := map[string]any{}
+ if headersMap, ok := msg.Params["headers"].(map[string]any); ok {
+ for k, v := range headersMap {
+ headers[k] = redactHeaderValue(k, v)
+ }
+ }
+ var okPtr *bool
+ if okVal, ok := boolFromAny(msg.Params["ok"]); ok {
+ okPtr = &okVal
+ }
+ if source == "" {
+ source = "unknown"
+ }
+ if phase == "" {
+ phase = "request"
+ }
+ return SniffedRequest{
+ Time: time.Now().Format(time.RFC3339),
+ Source: source,
+ Phase: phase,
+ Method: msg.Method,
+ URL: url,
+ Status: status,
+ OK: okPtr,
+ Headers: headers,
+ Payload: payload,
+ Response: response,
+ Error: errText,
+ }
+}
+
+func addSniff(sniff SniffedRequest) {
+ sniffMu.Lock()
+ defer sniffMu.Unlock()
+ sniffs = append([]SniffedRequest{sniff}, sniffs...)
+ if len(sniffs) > cfg.MaxSniffs {
+ sniffs = sniffs[:cfg.MaxSniffs]
+ }
+}
diff --git a/chatgpt-free-api/test.sh b/chatgpt-free-api/test.sh
new file mode 100755
index 0000000000000000000000000000000000000000..354eb930a8c4ebe019f52e7ab7c309eec3779d5c
--- /dev/null
+++ b/chatgpt-free-api/test.sh
@@ -0,0 +1,124 @@
+#!/bin/bash
+
+# Unset proxy variables for local communication
+unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy
+
+# Colors for output
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[0;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}==================================================${NC}"
+echo -e "${BLUE} ChatGPT Free API Integration Test ${NC}"
+echo -e "${BLUE}==================================================${NC}"
+
+# Check if agent is running on port 9225
+echo "🔍 Checking if agent is running on port 9225..."
+if ! curl -s --connect-timeout 2 http://127.0.0.1:9225/health >/dev/null; then
+ echo -e "${RED}❌ Error: Agent is not running on port 9225!${NC}"
+ echo -e "💡 Please start the server separately first by running: ${YELLOW}./agent${NC} or ${YELLOW}go run main.go${NC}"
+ exit 1
+fi
+echo -e "${GREEN}✅ Agent detected on port 9225! Running tests...${NC}"
+
+# Test 1: Check cookie status
+echo -e "\n${BLUE}[Test 1] Checking cookie status...${NC}"
+STATUS_RESP=$(curl -s http://127.0.0.1:9225/api/cookies/status)
+echo "Response: $STATUS_RESP"
+if [[ "$STATUS_RESP" == *"has_cookies\":true"* ]]; then
+ TEST1_STATUS="${GREEN}PASSED${NC}"
+else
+ TEST1_STATUS="${RED}FAILED (No cookies sync)${NC}"
+fi
+
+# Test 2: Send basic chat test
+echo -e "\n${BLUE}[Test 2] Sending basic text chat prompt...${NC}"
+CHAT_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \
+ -H "Content-Type: application/json" \
+ -d '{"prompt": "Tell me a short programmer joke."}')
+echo "Response: $CHAT_RESP"
+
+CONV_ID=$(echo "$CHAT_RESP" | grep -o '"conversation_id":"[^"]*' | cut -d'"' -f4)
+if [ -n "$CONV_ID" ]; then
+ TEST2_STATUS="${GREEN}PASSED${NC}"
+else
+ TEST2_STATUS="${RED}FAILED (No response or conversation ID)${NC}"
+fi
+
+# Test 3: Conversation thread continuity
+if [ -n "$CONV_ID" ]; then
+ echo -e "\n${BLUE}[Test 3] Testing thread continuity...${NC}"
+ THREAD_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \
+ -H "Content-Type: application/json" \
+ -d "{\"prompt\": \"Explain that joke.\", \"conversation_id\": \"$CONV_ID\"}")
+ echo "Response: $THREAD_RESP"
+ if [[ "$THREAD_RESP" == *"ok\":true"* ]]; then
+ TEST3_STATUS="${GREEN}PASSED${NC}"
+ else
+ TEST3_STATUS="${RED}FAILED (Thread reply failed)${NC}"
+ fi
+else
+ TEST3_STATUS="${YELLOW}SKIPPED (No conversation ID)${NC}"
+fi
+
+# Test 4: Bulk chat request
+echo -e "\n${BLUE}[Test 4] Testing bulk chat request...${NC}"
+BULK_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat/bulk \
+ -H "Content-Type: application/json" \
+ -d '{"prompts": ["What is 2+2?", "What is Go?"], "wait_for_response": true}')
+echo "Response: $BULK_RESP"
+if [[ "$BULK_RESP" == *"ok\":true"* ]]; then
+ TEST4_STATUS="${GREEN}PASSED${NC}"
+else
+ TEST4_STATUS="${RED}FAILED${NC}"
+fi
+
+# Test 5: Image generation (GPT-2)
+echo -e "\n${BLUE}[Test 5] Testing GPT-2 image generation (Cute baby dragon)...${NC}"
+echo "⏳ Polling for GPT-2 generation, this will take 15-35 seconds..."
+GEN_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \
+ -H "Content-Type: application/json" \
+ -d '{"prompt": "Create a cute 3D cartoon baby dragon sitting on a small chest of gold. Directly generate the image now without asking any questions or confirmation."}')
+echo "Response: $GEN_RESP"
+
+# Extract generated image path
+GEN_IMAGE_PATH=$(echo "$GEN_RESP" | grep -o '"images":\["[^"]*' | cut -d'"' -f4)
+if [ -n "$GEN_IMAGE_PATH" ] && [ -f "$GEN_IMAGE_PATH" ]; then
+ TEST5_STATUS="${GREEN}PASSED ($GEN_IMAGE_PATH)${NC}"
+else
+ TEST5_STATUS="${RED}FAILED (Image file not generated/saved)${NC}"
+fi
+
+# Test 6: Image editing (GPT-2 Edit/Vision)
+if [ -n "$GEN_IMAGE_PATH" ] && [ -f "$GEN_IMAGE_PATH" ]; then
+ echo -e "\n${BLUE}[Test 6] Testing GPT-2 image editing (Adding wizard hat)...${NC}"
+ echo "⏳ Uploading source image and polling for edit, this will take 15-35 seconds..."
+ EDIT_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat/edit \
+ -F "prompt=Add a small glowing wizard hat to the head of this dragon." \
+ -F "image=@$GEN_IMAGE_PATH")
+ echo "Response: $EDIT_RESP"
+
+ EDIT_IMAGE_PATH=$(echo "$EDIT_RESP" | grep -o '"images":\["[^"]*' | cut -d'"' -f4)
+ if [ -n "$EDIT_IMAGE_PATH" ] && [ -f "$EDIT_IMAGE_PATH" ]; then
+ TEST6_STATUS="${GREEN}PASSED ($EDIT_IMAGE_PATH)${NC}"
+ else
+ TEST6_STATUS="${RED}FAILED (Edited image file not generated/saved)${NC}"
+ fi
+else
+ TEST6_STATUS="${YELLOW}SKIPPED (No source image generated in Test 5)${NC}"
+fi
+
+echo -e "\n${BLUE}==================================================${NC}"
+echo -e "${BLUE} Test Summary ${NC}"
+echo -e "${BLUE}==================================================${NC}"
+echo -e "1. Cookie Status: $TEST1_STATUS"
+echo -e "2. Basic Chat: $TEST2_STATUS"
+echo -e "3. Thread Continuity: $TEST3_STATUS"
+echo -e "4. Bulk Chat: $TEST4_STATUS"
+echo -e "5. GPT-2 Gen: $TEST5_STATUS"
+echo -e "6. GPT-2 Image Editing: $TEST6_STATUS"
+echo -e "${BLUE}==================================================${NC}"
+
+echo -e "\n${GREEN}✅ Tests completed!${NC}\n"
diff --git a/flow-agent/.gitignore b/flow-agent/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..612715386468a314f1aa5e947311804661ebed78
--- /dev/null
+++ b/flow-agent/.gitignore
@@ -0,0 +1,15 @@
+*.mp4
+*.avi
+*.mov
+__pycache__/
+*.pyc
+.env
+venv/
+.DS_Store
+sniffed.json
+sniffed_all.json
+media_ids.json
+output/
+chunks/
+*.mp4
+__pycache__/
diff --git a/flow-agent/README.md b/flow-agent/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c1d3018984dcc859b07354bc4cfb3cd9d3001f6e
--- /dev/null
+++ b/flow-agent/README.md
@@ -0,0 +1,468 @@
+
+
+# ⚡ Flow Agent
+
+### Generate AI videos & images via HTTP/HTTPS API Server or CLI — no API key, no limits.
+
+**Omni Flash** for cinematic video generation · **Nano Banana 2** for unlimited image creation
+FastAPI Integration · Auto watermark removal · Reference-based editing · Zero setup.
+
+---
+
+🎬 `T2V` `V2V` `I2V` — Video generation with auto watermark clean *(uses credits)*
+🖼️ `T2I` `I2I` — Unlimited image generation with reference support *(no credits needed)*
+🔑 Uses your Google account via Chrome extension — **no API key required**
+🌐 FastAPI HTTP/HTTPS Server — **perfect for n8n & external automation**
+
+
+
+
+## ✅ Features & Status
+
+| Feature | What it does | Time | Status |
+|---------|-------------|------|--------|
+| **T2V** | Generate video from text prompt | ~44s | ✅ Working |
+| **T2I** | Generate image from text prompt | ~10-30s | ✅ Working |
+| **V2V** | Edit/restyle existing video | ~3min | ✅ Working |
+| **I2I** | Edit image with reference | ~10-30s | ✅ Working |
+| **I2V** | Animate a still image into video | ~44s | ✅ Working |
+| **FL** | First + Last frame video control | ~44s | ✅ Working |
+| **R2V** | Reference-based video generation | ~44s | ✅ Working |
+| **Upload** | Upload video/image to Flow | ~12s | ✅ Working |
+| **Watermark Remove** | Auto-remove Gemini watermark (~1s) | ~1s | ✅ Auto |
+| **Auto-Retry** | Auto-open/refresh Flow tab for token | auto | ✅ Built-in |
+| **API Sniffer** | Discover new endpoints/payloads | - | ✅ Working |
+
+---
+
+## 📋 Prerequisites
+
+| Requirement | Details |
+|-------------|---------|
+| **Python** | 3.9 or higher |
+| **Chrome** | Latest version |
+| **Google Account** | Logged into Flow |
+| **ffmpeg** | Only for V2V merge (optional) |
+
+---
+
+## 🛠️ Installation (Step by Step)
+
+### Step 1: Clone the repo
+
+```bash
+git clone https://github.com/kodelyx/flow-agent.git
+cd flow-agent
+```
+
+### Step 2: Install Python dependencies
+
+```bash
+pip install -r requirements.txt
+```
+
+This installs `websockets`, `opencv-python-headless`, and `numpy`.
+
+### Step 3: Install the Chrome Extension
+
+1. Open Chrome browser
+2. Go to `chrome://extensions` in the address bar
+3. Toggle **"Developer mode"** ON (top-right corner)
+4. Click **"Load unpacked"**
+5. Select the `extension/` folder from this repo
+6. You should see the **Flow Agent** extension appear
+
+### Step 4: Open Google Flow
+
+1. Open [labs.google/fx/tools/flow](https://labs.google/fx/tools/flow) in Chrome
+2. Make sure you're **logged into your Google account**
+3. The extension icon should show a **green badge** = connected
+4. The extension auto-opens this tab when needed
+
+> ⚠️ The Flow tab auto-opens when you run a command. No manual tab management needed!
+
+---
+
+## 🚀 Usage
+
+### Text → Video (T2V)
+
+Generate a new video from a text description.
+
+```bash
+# Basic (portrait 9:16, 10 seconds)
+python -m cli.generate "A samurai drawing his katana on a cliff at golden sunset"
+
+# Landscape mode (16:9)
+python -m cli.generate "Eagle soaring over snowy mountains" --aspect landscape
+
+# Custom output file
+python -m cli.generate "A dragon breathing fire" -o dragon.mp4
+
+# Shorter duration (4/6/8/10 seconds)
+python -m cli.generate "Dog playing in the park" --duration 6
+
+# Generate multiple variations
+python -m cli.generate "Cyberpunk city at night" --count 4
+
+# I2V — animate a still image
+python -m cli.generate "Character comes alive" --start photo.png
+
+# FL — First + Last frame (controlled transition)
+python -m cli.generate "Person walks forward" --start start.png --end end.png
+
+# R2V — Reference images (character consistency)
+python -m cli.generate "Character in new scene" --ref char1.png char2.png
+```
+
+**CLI Options:**
+| Flag | Short | Default | Description |
+|------|-------|---------|-------------|
+| `--output` | `-o` | `omni_output.mp4` | Output filename |
+| `--aspect` | `-a` | `portrait` | `portrait` or `landscape` |
+| `--duration` | `-d` | `10` | `4`, `6`, `8`, or `10` seconds |
+| `--count` | `-c` | `1` | Generate 1-4 videos |
+| `--edit` | `-e` | - | Pass media_id for V2V edit mode |
+| `--start` | `-s` | - | Start frame image (I2V / FL mode) |
+| `--end` | | - | End frame image (use with --start for FL) |
+| `--ref` | `-r` | - | Reference image(s) for R2V mode |
+| `--no-clean` | | - | Skip auto watermark removal |
+
+---
+
+### Text → Image (T2I)
+
+Generate images from a text description.
+
+```bash
+# Basic (portrait 9:16)
+python -m cli.image "A dragon breathing fire in a cyberpunk city"
+
+# Landscape
+python -m cli.image "Mountain sunset" --aspect landscape -o sunset.png
+
+# Square (for logos, icons)
+python -m cli.image "Minimal logo design" --aspect square
+
+# Generate 4 variations
+python -m cli.image "Abstract art" --count 4
+
+# I2I: Edit with reference image
+python -m cli.image "Make it anime style" --ref original.png -o anime.png
+```
+
+**CLI Options:**
+| Flag | Short | Default | Description |
+|------|-------|---------|-------------|
+| `--output` | `-o` | `output/image.png` | Output filename |
+| `--aspect` | `-a` | `portrait` | `portrait`, `landscape`, `square`, `4x3`, `3x4` |
+| `--count` | `-c` | `1` | Generate 1-4 variations |
+| `--ref` | `-r` | - | Reference image(s) for I2I |
+
+### Upload Video
+
+Upload a local video to Google Flow. Returns a `media_id` needed for V2V editing.
+
+```bash
+# Single video
+python -m cli.upload my_video.mp4
+
+# Batch upload all .mp4 in a folder
+python -m cli.upload chunks/ --batch
+```
+
+The `media_id` is **automatically saved** to `media-id.js`:
+```
+my_video.mp4 : 49f7d936-01e3-41ad-917a-2f9bb6ead00b
+```
+
+---
+
+### Video → Video Edit (V2V)
+
+Apply style changes to an uploaded video (e.g., convert to anime).
+
+```bash
+# Step 1: Upload your video
+python -m cli.upload my_video.mp4
+# → media_id saved to media-id.js
+
+# Step 2: Edit with style prompt
+python -m cli.edit "Transform into vibrant anime style, Studio Ghibli aesthetic" \
+ --media-id 49f7d936-01e3-41ad-917a-2f9bb6ead00b \
+ --video-file my_video.mp4 \
+ --output output_anime/ \
+ --merge
+
+# Without local video file (specify duration manually)
+python -m cli.edit "Make it look cyberpunk neon" \
+ -m MEDIA_ID \
+ --total-seconds 30 \
+ -o output_cyber/
+```
+
+**How it works:** Long videos are automatically split into 10s segments, each processed in parallel, then merged with ffmpeg.
+
+**CLI Options:**
+| Flag | Short | Required | Description |
+|------|-------|----------|-------------|
+| `--media-id` | `-m` | Yes | Flow media ID (from upload) |
+| `--video-file` | `-v` | No | Local file (auto-detects duration/fps) |
+| `--total-seconds` | `-t` | No | Duration if no local file |
+| `--output` | `-o` | No | Output directory (default: `output/`) |
+| `--aspect` | `-a` | No | `portrait` or `landscape` |
+| `--merge` | | No | Merge segments with ffmpeg |
+
+---
+
+### Image → Video (I2V)
+
+Animate a still image into a video. Requires Python scripting (no CLI yet):
+
+```python
+import asyncio
+from omniflash import (
+ ExtensionBridge, upload_image, generate_video_i2v,
+ poll_status, download_video, ASPECTS, DEFAULT_PROJECT,
+)
+
+async def main():
+ # Connect to extension
+ bridge = ExtensionBridge()
+ await bridge.start()
+ await bridge.wait_for_extension(30)
+
+ # Upload your image
+ img_id = await upload_image(bridge, "my_image.png")
+ print(f"Image uploaded: {img_id}")
+
+ # Generate video from image
+ media_ids = await generate_video_i2v(
+ bridge,
+ prompt="The character comes alive, dramatic movement, cinematic",
+ aspect=ASPECTS['portrait'], # or ASPECTS['landscape']
+ project_id=DEFAULT_PROJECT,
+ image_media_id=img_id,
+ duration=8, # 4, 6, 8, or 10 seconds
+ )
+
+ # Wait for video to finish
+ if media_ids:
+ await poll_status(bridge, media_ids[0], DEFAULT_PROJECT)
+ await download_video(bridge, media_ids[0], "output_i2v.mp4")
+
+ await bridge.close()
+
+asyncio.run(main())
+```
+
+---
+
+### API Sniffer
+
+Capture all API requests made by the Flow UI. Useful for discovering new endpoints or debugging.
+
+```bash
+# Start sniffer, then use Flow UI normally
+python -m cli.sniff
+
+# Save captured requests to file
+python -m cli.sniff --save sniffed.json
+```
+
+See [SNIFFING.md](SNIFFING.md) for the full API discovery guide.
+
+---
+
+## 🌐 HTTP/HTTPS API Server (for n8n & external integrations)
+
+A FastAPI-based API server is included to trigger all Flow Agent features remotely (e.g., from n8n HTTP Request nodes, custom webhooks, or automation workflows).
+
+### Start the API Server
+
+Run the server from the project directory:
+```bash
+# Standard HTTP (defaults to port 8000)
+venv/bin/python -m cli.api --host 0.0.0.0 --port 8000
+
+# Optional HTTPS (auto-generates self-signed SSL certificates)
+venv/bin/python -m cli.api --host 0.0.0.0 --port 8443 --ssl
+```
+
+### Core API Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| **GET** | `/health` | Check Extension Bridge health and token status. |
+| **POST** | `/generate/video` | T2V, I2V, FL, R2V video generation. Supports query `?download=true` to stream the binary `.mp4` file. |
+| **POST** | `/generate/image` | T2I, I2I image generation. Supports `?download=true` to stream the binary `.png` file. |
+| **POST** | `/upload/image` | Upload an image to Flow (via file upload or local file path). |
+| **POST** | `/upload/video` | Upload a video to Flow (via file upload or local file path). |
+| **POST** | `/edit/video` | V2V video editing (restyling segment duration). |
+| **GET** | `/download/{filename}` | Download generated image/video files from the `output/` folder. |
+
+### Verify with Integration Tests
+
+A comprehensive integration test script is provided to verify all endpoints:
+```bash
+venv/bin/python test_api.py
+```
+
+See [error.md](error.md) for detailed troubleshooting instructions if you encounter port conflicts or proxy network issues.
+
+---
+
+## 🐍 Python API (for developers)
+
+Use the `omniflash` package directly in your own scripts:
+
+```python
+from omniflash import (
+ ExtensionBridge, # WebSocket bridge to Chrome extension
+ generate_video, # T2V: text → video
+ edit_video, # V2V: video → video
+ upload_image, # Upload image → get media_id
+ generate_video_i2v, # I2V: image → video
+ poll_status, # Poll until video is ready
+ download_video, # Download finished video
+ ASPECTS, # {'portrait': '...', 'landscape': '...'}
+ DEFAULT_PROJECT, # Your default project ID
+)
+from omniflash.upload import upload_video # Upload video file
+from omniflash import media_store # Read/write media-id.js
+
+# media_store examples:
+media_store.save("video.mp4", "uuid-here") # Save entry
+mid = media_store.get("video.mp4") # Get media_id
+all_entries = media_store.read_entries() # Get all entries
+```
+
+> **Backward compatible:** `from omni import ExtensionBridge, generate_video, ...` still works.
+
+---
+
+## 📁 Project Structure
+
+```
+flow-agent/
+├── omniflash/ # Core Python package
+│ ├── __init__.py # Public API exports
+│ ├── bridge.py # ExtensionBridge (WS + HTTP + auto-retry)
+│ ├── config.py # All config hardcoded here
+│ ├── media_store.py # media-id.js read/write
+│ ├── upload.py # Video upload (GCS resumable)
+│ ├── watermark.py # Auto watermark removal (embedded assets)
+│ └── generators/ # API functions
+│ ├── common.py # poll_status, download_video
+│ ├── t2v.py # Text → Video
+│ ├── t2i.py # Text → Image + I2I
+│ ├── v2v.py # Video → Video (edit)
+│ └── i2v.py # Image → Video + upload_image
+├── cli/ # CLI entry points
+│ ├── generate.py # python -m cli.generate
+│ ├── image.py # python -m cli.image
+│ ├── upload.py # python -m cli.upload
+│ ├── edit.py # python -m cli.edit
+│ └── sniff.py # python -m cli.sniff
+├── extension/ # Chrome extension
+│ ├── manifest.json # Extension manifest
+│ ├── background.js # WS client, API proxy
+│ ├── content.js # Page ↔ background bridge
+│ └── injected.js # Fetch interceptor, reCAPTCHA
+├── omni.py # Backward-compatible wrapper
+├── .gitignore # Git ignore rules
+├── media-id.js # Auto-updated filename → media_id
+├── requirements.txt # Python dependencies
+├── SNIFFING.md # API discovery guide
+└── README.md
+```
+
+---
+
+## ⚙️ How It Works
+
+```
+┌─────────────────────────────────┐
+│ Your Terminal / Python Script │
+│ python -m cli.generate "..." │
+└──────────┬──────────────────────┘
+ │ import omniflash
+ ▼
+┌─────────────────────────────────┐
+│ omniflash package │
+│ ExtensionBridge (WS + HTTP) │
+└──────────┬──────────────────────┘
+ │ WebSocket (:9222)
+ │ HTTP callback (:8100)
+ ▼
+┌─────────────────────────────────┐
+│ Chrome Extension (Flow Agent) │
+│ Auth token + reCAPTCHA solving │
+└──────────┬──────────────────────┘
+ │ HTTPS (browser cookies)
+ ▼
+┌─────────────────────────────────┐
+│ Google Omni API (aisandbox) │
+│ Video generation / editing │
+└─────────────────────────────────┘
+```
+
+1. Python starts a WebSocket server + HTTP callback server
+2. Chrome extension auto-connects and provides authentication
+3. Script sends API requests through the extension
+4. Extension solves reCAPTCHA and proxies with browser cookies
+5. Script polls for completion, then downloads the result
+
+---
+
+## 🎯 Models & Endpoints
+
+| Model | Key | Duration | Type |
+|-------|-----|----------|------|
+| Omni Flash T2V 4s | `abra_t2v_4s` | 4 sec | Text → Video |
+| Omni Flash T2V 6s | `abra_t2v_6s` | 6 sec | Text → Video |
+| Omni Flash T2V 8s | `abra_t2v_8s` | 8 sec | Text → Video |
+| Omni Flash T2V 10s | `abra_t2v_10s` | 10 sec | Text → Video |
+| Omni Flash Edit | `abra_edit` | 10 sec | Video → Video |
+
+| Endpoint | Path |
+|----------|------|
+| T2V | `/v1/video:batchAsyncGenerateVideoText` |
+| I2V | `/v1/video:batchAsyncGenerateVideoStartImage` |
+| V2V Edit | `/v1/video:batchAsyncGenerateVideoEditVideo` |
+| Upload Image | `/v1/flow/uploadImage` |
+| Poll Status | `/v1/video:batchCheckAsyncVideoGenerationStatus` |
+| Get Media | `/v1/video/media/{media_id}` |
+
+---
+
+## 🔧 Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| Extension not connecting | Make sure Flow tab is open and you're logged in |
+| `Address already in use` | Another script is using port 9222/8100. Kill it first |
+| `TIMEOUT` error | Extension may have disconnected. Reload Flow tab |
+| `reCAPTCHA failed` | Reload Flow tab, wait a few seconds, try again |
+| `No media in response` | Check your prompt. Some prompts get blocked |
+| `curl failed` | Upload too large or network issue. Retry |
+| Video quality poor | Use longer duration (10s) and detailed prompts |
+| V2V merge fails | Install ffmpeg: `brew install ffmpeg` |
+
+---
+
+## ⚠️ Important Notes
+
+- **Flow tab auto-opens** — no manual tab management needed
+- Uses your Google account's **free Flow credits** (check remaining in Flow UI)
+- Extension auto-reconnects and auto-retries (3 attempts)
+- **Watermark auto-removed** on every generated video (~1s)
+- `media-id.js` auto-updates on every upload (video or image)
+- All generated videos save to the `output/` directory by default
+- Old `from omni import ...` syntax still works (backward compatible)
+
+---
+
+## 📄 License
+
+MIT
diff --git a/flow-agent/SNIFFING.md b/flow-agent/SNIFFING.md
new file mode 100644
index 0000000000000000000000000000000000000000..43ecca02ef51a0fa6082ab5120f4ad0e8a3e1270
--- /dev/null
+++ b/flow-agent/SNIFFING.md
@@ -0,0 +1,205 @@
+# 🔍 API Sniffing Guide
+
+How to discover new Google Flow API endpoints using the Chrome extension's built-in request sniffer.
+
+## How It Works
+
+```
+Flow UI (browser)
+ ↓ user clicks "Generate" / "Upload" etc.
+ ↓
+fetch() call to aisandbox-pa.googleapis.com
+ ↓ intercepted by injected.js (monkey-patched fetch)
+ ↓
+postMessage → content.js → background.js
+ ↓
+HTTP POST → http://127.0.0.1:8100/api/ext/callback
+ ↓
+Your sniff server logs the URL + payload
+```
+
+The extension's `injected.js` monkey-patches `window.fetch` to intercept ALL outgoing requests to `aisandbox-pa.googleapis.com`. Every request's URL, method, and body are forwarded to your local server.
+
+## Quick Start
+
+### 1. Start the Sniff Server
+
+```python
+python sniff.py
+```
+
+This starts:
+- WebSocket server on `ws://127.0.0.1:9222` (extension connects here)
+- HTTP server on `http://127.0.0.1:8100` (receives sniffed data)
+
+### 2. Open Flow UI
+
+Go to [labs.google/fx/tools/flow](https://labs.google/fx/tools/flow) in Chrome.
+The extension will auto-connect to your sniff server.
+
+### 3. Perform the Action
+
+Do whatever you want to discover the API for:
+- Upload an image/video
+- Generate a video
+- Change settings
+- Click any button
+
+### 4. Read the Logs
+
+The sniff server prints every intercepted request:
+```
+🔍 SNIFFED: https://aisandbox-pa.googleapis.com/v1/video:batchAsyncGenerateVideoText
+ Method: POST
+ Payload: {"mediaGenerationContext":{"batchId":"..."},"clientContext":{...},"requests":[...]}
+```
+
+## Sniff Server Code
+
+Save this as `sniff.py`:
+
+```python
+#!/usr/bin/env python3
+"""Sniff server — captures all Flow UI API requests."""
+
+import asyncio, json, websockets, logging
+from http.server import HTTPServer, BaseHTTPRequestHandler
+import threading
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S')
+log = logging.getLogger('sniff')
+
+# Store all sniffed requests
+all_requests = []
+
+class Handler(BaseHTTPRequestHandler):
+ def do_POST(self):
+ length = int(self.headers.get('Content-Length', 0))
+ body = json.loads(self.rfile.read(length)) if length else {}
+
+ if body.get('type') == 'sniffed_video_request':
+ url = body.get('url', '')
+ method = body.get('method', '?')
+ payload = body.get('payload', '')
+
+ log.info('🔍 %s %s', method, url)
+ if payload:
+ log.info(' %s', str(payload)[:1000])
+
+ all_requests.append({
+ 'url': url,
+ 'method': method,
+ 'payload': payload,
+ 'timestamp': body.get('timestamp'),
+ })
+
+ self.send_response(200)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ self.wfile.write(b'{"ok":true}')
+
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Access-Control-Allow-Methods', 'POST')
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
+ self.end_headers()
+
+ def log_message(self, *a): pass
+
+# Start HTTP server
+srv = HTTPServer(('127.0.0.1', 8100), Handler)
+threading.Thread(target=srv.serve_forever, daemon=True).start()
+log.info('HTTP callback on :8100')
+
+async def on_connect(ws):
+ log.info('✅ Extension connected!')
+ async for raw in ws:
+ data = json.loads(raw)
+ if data.get('type') == 'token_captured':
+ log.info('🔑 Token captured')
+
+async def main():
+ async with websockets.serve(on_connect, '127.0.0.1', 9222):
+ log.info('⚡ WS server on :9222')
+ log.info('👉 Open Flow UI and perform any action...')
+ await asyncio.Future()
+
+asyncio.run(main())
+```
+
+## What Gets Captured
+
+| Action in Flow UI | API Endpoint |
+|-------------------|-------------|
+| Generate video (T2V) | `/v1/video:batchAsyncGenerateVideoText` |
+| Generate video (I2V) | `/v1/video:batchAsyncGenerateVideoStartImage` |
+| Generate video (Edit) | `/v1/video:batchAsyncGenerateVideoEditVideo` |
+| Poll video status | `/v1/video:batchCheckAsyncVideoGenerationStatus` |
+| Upload image | `/v1/flow/uploadImage` |
+| Generate image | `/v1/projects/{id}/flowMedia:batchGenerateImages` |
+| Get credits | `/v1/credits` |
+| Get media | `/v1/media/{media_id}` |
+| Upscale video | `/v1/video:batchAsyncGenerateVideoUpsampleVideo` |
+
+## How to Find New Endpoints
+
+### Example: Finding Video Upload
+
+1. Start `sniff.py`
+2. Open Flow UI
+3. Drag & drop a video file into Flow
+4. Check logs — you'll see the upload URL and payload format
+5. Add the new endpoint to `models.json`
+
+### Example: Finding Model Keys
+
+1. Start `sniff.py`
+2. Open Flow UI
+3. Select different model (e.g., Omni Flash 4s)
+4. Click Generate
+5. Check logs — look for `videoModelKey` in the payload
+
+```json
+"requests": [{
+ "videoModelKey": "abra_t2v_4s", ← this is what you need
+ ...
+}]
+```
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────┐
+│ injected.js (MAIN world) │
+│ - Monkey-patches window.fetch │
+│ - Captures URL + body of every request │
+│ - Posts to content.js via postMessage │
+└──────────────────┬──────────────────────────┘
+ │ postMessage
+┌──────────────────▼──────────────────────────┐
+│ content.js (ISOLATED world) │
+│ - Listens for __FLOWKIT_SNIFF__ messages │
+│ - Forwards to background.js │
+└──────────────────┬──────────────────────────┘
+ │ chrome.runtime.sendMessage
+┌──────────────────▼──────────────────────────┐
+│ background.js (Service Worker) │
+│ - Receives SNIFFED_AISANDBOX_REQUEST │
+│ - POSTs to http://127.0.0.1:8100/callback │
+└──────────────────┬──────────────────────────┘
+ │ HTTP POST
+┌──────────────────▼──────────────────────────┐
+│ sniff.py (Your server) │
+│ - Logs every request │
+│ - Saves URL + method + payload │
+└─────────────────────────────────────────────┘
+```
+
+## Tips
+
+- **Filter by keyword**: Modify the sniff server to only log URLs containing specific words (e.g., `upload`, `video`, `generate`)
+- **Save to file**: Add `json.dump(all_requests, open('sniffed.json', 'w'))` to save all captured requests
+- **Compare payloads**: Run the same action with different settings and diff the payloads to find which fields control what
+- **Telemetry noise**: Ignore URLs containing `batchLog`, `fetchUserRecommendations`, `frontendEvents` — these are analytics, not API calls
diff --git a/flow-agent/cli/api.py b/flow-agent/cli/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..e78370c09009c80c788ae33f38f65c359968ac11
--- /dev/null
+++ b/flow-agent/cli/api.py
@@ -0,0 +1,617 @@
+#!/usr/bin/env python3
+"""FastAPI Server for Flow Agent — expose CLI functionality via HTTP/HTTPS.
+
+Allows n8n and other remote systems to trigger video/image generation and upload assets.
+"""
+
+import os
+import sys
+import uuid
+import time
+import shutil
+import base64
+import logging
+import asyncio
+from typing import List, Optional
+from contextlib import asynccontextmanager
+
+# Add parent dir to sys.path so omniflash can be imported
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query
+from fastapi.responses import FileResponse, JSONResponse
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel, Field
+
+from omniflash import (
+ ExtensionBridge, generate_video, edit_video,
+ poll_status, download_video, ASPECTS, DEFAULT_PROJECT,
+)
+from omniflash.generators.i2v import upload_image, generate_video_i2v, generate_video_fl, generate_video_r2v
+from omniflash.generators.t2i import generate_image, download_image, IMAGE_ASPECTS
+from omniflash.upload import upload_video
+
+# Setup logging
+log = logging.getLogger("omniflash.api")
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
+
+# Ensure required directories exist
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+OUTPUT_DIR = os.path.join(ROOT_DIR, "output")
+TEMP_DIR = os.path.join(OUTPUT_DIR, ".temp")
+
+def ensure_temp_dir():
+ os.makedirs(TEMP_DIR, exist_ok=True)
+
+def cleanup_temp_dir():
+ try:
+ if os.path.exists(TEMP_DIR) and not os.listdir(TEMP_DIR):
+ os.rmdir(TEMP_DIR)
+ except Exception:
+ pass
+
+# Global ExtensionBridge instance
+bridge: Optional[ExtensionBridge] = None
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ global bridge
+ log.info("🚀 Starting Flow Agent Extension Bridge...")
+ bridge = ExtensionBridge()
+ await bridge.start()
+
+ # Run extension connection in background so the API server starts immediately
+ asyncio.create_task(bridge.wait_for_extension(timeout=30))
+
+ yield
+
+ log.info("🔌 Closing Flow Agent Extension Bridge...")
+ if bridge:
+ await bridge.close()
+ cleanup_temp_dir()
+
+app = FastAPI(
+ title="Flow Agent API",
+ description="API Server to trigger Google Flow AI video and image generation",
+ version="1.0.0",
+ lifespan=lifespan
+)
+
+# Enable CORS for convenience
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Helper function to check/reconnect the bridge
+async def get_active_bridge() -> ExtensionBridge:
+ global bridge
+ if not bridge:
+ raise HTTPException(status_code=503, detail="Extension bridge is not initialized")
+
+ # Try a quick health check
+ is_healthy = await bridge.health_check()
+ if not is_healthy:
+ log.info("🔄 Bridge health check failed. Re-waiting for extension connection...")
+ # Attempt to reconnect / grab flowKey
+ connected = await bridge.wait_for_extension(timeout=10, max_retries=1)
+ if not connected:
+ raise HTTPException(
+ status_code=503,
+ detail="Google Flow extension is not connected or unauthorized. Make sure Google Flow tab is open in Chrome."
+ )
+ return bridge
+
+# Helper to process image inputs (local path, media_id, or base64 data)
+async def resolve_image_input(active_bridge: ExtensionBridge, path_or_id_or_b64: str, project_id: str) -> str:
+ if not path_or_id_or_b64:
+ return ""
+
+ # Case 1: Base64 data (e.g. data:image/png;base64,... or raw base64)
+ if path_or_id_or_b64.startswith("data:") or len(path_or_id_or_b64) > 500:
+ try:
+ if "," in path_or_id_or_b64:
+ base64_data = path_or_id_or_b64.split(",", 1)[1]
+ else:
+ base64_data = path_or_id_or_b64
+
+ img_bytes = base64.b64decode(base64_data)
+ temp_filename = f"b64_{uuid.uuid4().hex}.png"
+ ensure_temp_dir()
+ temp_path = os.path.join(TEMP_DIR, temp_filename)
+ with open(temp_path, "wb") as f:
+ f.write(img_bytes)
+
+ mid = await upload_image(active_bridge, temp_path, project_id)
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ cleanup_temp_dir()
+
+ if not mid:
+ raise HTTPException(status_code=400, detail="Failed to upload base64 image reference")
+ return mid
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Failed parsing base64 image: {str(e)}")
+
+ # Case 2: Local file path
+ if os.path.exists(path_or_id_or_b64):
+ mid = await upload_image(active_bridge, path_or_id_or_b64, project_id)
+ if not mid:
+ raise HTTPException(status_code=400, detail=f"Failed to upload local image path: {path_or_id_or_b64}")
+ return mid
+
+ # Case 3: Already a Media ID (UUID or similar format)
+ return path_or_id_or_b64
+
+
+# Request Models
+class VideoGenerationRequest(BaseModel):
+ prompt: str = Field(..., description="Text prompt for video generation")
+ aspect: str = Field("portrait", description="Aspect ratio: 'portrait' or 'landscape'")
+ duration: int = Field(10, description="Duration in seconds: 4, 6, 8, or 10")
+ count: int = Field(1, description="Number of variations (1-4)")
+ project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID")
+ start: Optional[str] = Field(None, description="Start frame image (file path, media_id, or base64)")
+ end: Optional[str] = Field(None, description="End frame image (use with start for FL mode)")
+ ref: Optional[List[str]] = Field(None, description="Reference image(s) (file path, media_id, or base64)")
+ edit: Optional[str] = Field(None, description="Flow video media_id for video editing (V2V)")
+ no_clean: bool = Field(False, description="Skip watermark removal")
+
+
+class ImageGenerationRequest(BaseModel):
+ prompt: str = Field(..., description="Text prompt for image generation")
+ aspect: str = Field("portrait", description="Aspect ratio: 'portrait', 'landscape', 'square', '4x3', '3x4'")
+ count: int = Field(1, description="Number of variations (1-4)")
+ ref: Optional[List[str]] = Field(None, description="Reference image(s) (file path, media_id, or base64)")
+ project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID")
+
+
+class VideoEditRequest(BaseModel):
+ prompt: str = Field(..., description="Restyle/edit text prompt")
+ video_media_id: str = Field(..., description="Original video media_id")
+ aspect: str = Field("portrait", description="Aspect ratio: 'portrait' or 'landscape'")
+ fps: int = Field(24, description="FPS of source video")
+ duration: int = Field(10, description="Duration of segment to edit")
+ start_frame: int = Field(0, description="Start frame index")
+ end_frame: Optional[int] = Field(None, description="End frame index")
+ project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID")
+ download: bool = Field(False, description="Directly download binary video stream")
+
+
+# API Routes
+
+@app.get("/health")
+async def health():
+ """Check API server connection and Chrome extension authorization."""
+ global bridge
+ if not bridge:
+ return {"status": "starting", "extension_connected": False, "has_flow_key": False}
+
+ is_healthy = await bridge.health_check()
+ return {
+ "status": "healthy" if is_healthy else "disconnected_or_unauthorized",
+ "extension_connected": bridge._ws is not None,
+ "has_flow_key": bridge._flow_key is not None
+ }
+
+
+@app.post("/upload/image")
+async def api_upload_image(
+ file: Optional[UploadFile] = File(None),
+ path: Optional[str] = Form(None),
+ project_id: str = Form(DEFAULT_PROJECT)
+):
+ """Upload an image to Google Flow. Accepts multipart file upload or local file path."""
+ active_bridge = await get_active_bridge()
+
+ temp_path = None
+ if file:
+ temp_filename = f"upload_{uuid.uuid4().hex}_{file.filename}"
+ ensure_temp_dir()
+ temp_path = os.path.join(TEMP_DIR, temp_filename)
+ with open(temp_path, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+ upload_path = temp_path
+ elif path:
+ if not os.path.exists(path):
+ raise HTTPException(status_code=404, detail=f"Local file not found: {path}")
+ upload_path = path
+ else:
+ raise HTTPException(status_code=400, detail="Must provide 'file' (multipart) or 'path' (form parameter)")
+
+ try:
+ media_id = await upload_image(active_bridge, upload_path, project_id)
+ if not media_id:
+ raise HTTPException(status_code=500, detail="Flow image upload failed")
+ return {"success": True, "media_id": media_id}
+ finally:
+ if temp_path and os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ cleanup_temp_dir()
+
+
+@app.post("/upload/video")
+async def api_upload_video(
+ file: Optional[UploadFile] = File(None),
+ path: Optional[str] = Form(None),
+ project_id: str = Form(DEFAULT_PROJECT)
+):
+ """Upload a video to Google Flow. Accepts multipart file upload or local file path."""
+ active_bridge = await get_active_bridge()
+
+ temp_path = None
+ if file:
+ temp_filename = f"upload_{uuid.uuid4().hex}_{file.filename}"
+ ensure_temp_dir()
+ temp_path = os.path.join(TEMP_DIR, temp_filename)
+ with open(temp_path, "wb") as f:
+ shutil.copyfileobj(file.file, f)
+ upload_path = temp_path
+ elif path:
+ if not os.path.exists(path):
+ raise HTTPException(status_code=404, detail=f"Local file not found: {path}")
+ upload_path = path
+ else:
+ raise HTTPException(status_code=400, detail="Must provide 'file' (multipart) or 'path' (form parameter)")
+
+ try:
+ result = await upload_video(upload_path, project_id, active_bridge)
+ media_id = result.get("mediaId") or result.get("name") or result.get("id")
+ if not media_id and isinstance(result.get("media"), dict):
+ media_id = result["media"].get("name") or result["media"].get("mediaId")
+
+ if not media_id:
+ raise HTTPException(status_code=500, detail=f"Flow video upload failed: {result}")
+ return {"success": True, "media_id": media_id, "data": result}
+ finally:
+ if temp_path and os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ cleanup_temp_dir()
+
+
+@app.post("/generate/video")
+async def api_generate_video(req: VideoGenerationRequest, download: bool = Query(False)):
+ """Generate or edit video via text prompt and optional references (T2V, I2V, FL, R2V, V2V)."""
+ active_bridge = await get_active_bridge()
+ aspect = ASPECTS.get(req.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
+
+ # 1. Resolve starting image (I2V / FL)
+ start_id = None
+ if req.start:
+ start_id = await resolve_image_input(active_bridge, req.start, req.project_id)
+
+ # 2. Resolve end image (FL)
+ end_id = None
+ if req.end:
+ end_id = await resolve_image_input(active_bridge, req.end, req.project_id)
+
+ # 3. Resolve reference images (R2V)
+ ref_ids = []
+ if req.ref:
+ for r in req.ref:
+ mid = await resolve_image_input(active_bridge, r, req.project_id)
+ if mid:
+ ref_ids.append(mid)
+
+ # 4. Trigger generation
+ media_ids = None
+ if start_id and end_id:
+ media_ids = await generate_video_fl(
+ active_bridge, req.prompt, aspect, req.project_id,
+ start_image_id=start_id, end_image_id=end_id, duration=req.duration
+ )
+ elif start_id:
+ media_ids = await generate_video_i2v(
+ active_bridge, req.prompt, aspect, req.project_id,
+ image_media_id=start_id, duration=req.duration
+ )
+ elif ref_ids:
+ media_ids = await generate_video_r2v(
+ active_bridge, req.prompt, aspect, req.project_id,
+ ref_media_ids=ref_ids, duration=req.duration
+ )
+ elif req.edit:
+ media_ids = await edit_video(
+ active_bridge, req.prompt, aspect, req.project_id,
+ video_media_id=req.edit, duration=req.duration
+ )
+ else:
+ media_ids = await generate_video(
+ active_bridge, req.prompt, aspect, req.project_id,
+ duration=req.duration, count=req.count
+ )
+
+ if not media_ids:
+ raise HTTPException(status_code=500, detail="Failed to initiate video generation")
+
+ outputs = []
+ timestamp = int(time.time())
+
+ # 5. Poll and Download
+ for i, media_id in enumerate(media_ids):
+ log.info(f"Polling video [{i+1}/{len(media_ids)}] ID: {media_id}")
+ if not await poll_status(active_bridge, media_id, req.project_id):
+ log.error(f"Polling failed for media ID: {media_id}")
+ continue
+
+ unique_id = uuid.uuid4().hex[:6]
+ filename = f"omni_{timestamp}_{unique_id}_{i+1}.mp4"
+ out_path = os.path.join(OUTPUT_DIR, filename)
+ ensure_temp_dir()
+ temp_path = os.path.join(TEMP_DIR, filename)
+
+ if await download_video(active_bridge, media_id, temp_path):
+ if not req.no_clean:
+ try:
+ from omniflash.watermark import remove_watermark_video
+ remove_watermark_video(temp_path, out_path)
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ except Exception as e:
+ log.warning(f"Watermark removal failed: {e}. Fallback to raw video.")
+ os.replace(temp_path, out_path)
+ else:
+ os.replace(temp_path, out_path)
+
+ outputs.append({
+ "media_id": media_id,
+ "filename": filename,
+ "local_path": out_path,
+ "download_url": f"/download/{filename}"
+ })
+
+ cleanup_temp_dir()
+ if not outputs:
+ raise HTTPException(status_code=500, detail="Failed to download generated video(s)")
+
+ # Return binary directly if requested and single file
+ if download and len(outputs) == 1:
+ return FileResponse(
+ path=outputs[0]["local_path"],
+ filename=outputs[0]["filename"],
+ media_type="video/mp4"
+ )
+
+ return {
+ "success": True,
+ "outputs": outputs
+ }
+
+
+@app.post("/generate/image")
+async def api_generate_image(req: ImageGenerationRequest, download: bool = Query(False)):
+ """Generate image using text prompt and optional reference images (T2I, I2I)."""
+ active_bridge = await get_active_bridge()
+ aspect = req.aspect
+
+ # Resolve reference images if any
+ ref_ids = []
+ if req.ref:
+ for r in req.ref:
+ mid = await resolve_image_input(active_bridge, r, req.project_id)
+ if mid:
+ ref_ids.append(mid)
+
+ results = await generate_image(
+ active_bridge, req.prompt, aspect, req.project_id,
+ count=req.count, ref_media_ids=ref_ids or None
+ )
+
+ if not results:
+ raise HTTPException(status_code=500, detail="Failed to generate image")
+
+ outputs = []
+ timestamp = int(time.time())
+
+ for i, r in enumerate(results):
+ url = r.get("image_url")
+ media_id = r.get("media_id")
+ if not url:
+ continue
+
+ unique_id = uuid.uuid4().hex[:6]
+ filename = f"img_{timestamp}_{unique_id}_{i+1}.png"
+ out_path = os.path.join(OUTPUT_DIR, filename)
+
+ download_success = await download_image(active_bridge, url, out_path)
+
+ outputs.append({
+ "media_id": media_id,
+ "filename": filename,
+ "local_path": out_path if download_success else None,
+ "download_url": f"/download/{filename}" if download_success else None,
+ "remote_url": url,
+ "downloaded": download_success
+ })
+
+ # Return binary directly if requested, single image, and it was successfully downloaded
+ if download and len(outputs) == 1 and outputs[0]["downloaded"]:
+ return FileResponse(
+ path=outputs[0]["local_path"],
+ filename=outputs[0]["filename"],
+ media_type="image/png"
+ )
+
+ return {
+ "success": True,
+ "outputs": outputs
+ }
+
+
+@app.post("/edit/video")
+async def api_edit_video(req: VideoEditRequest):
+ """Submit V2V edit request."""
+ active_bridge = await get_active_bridge()
+ aspect = ASPECTS.get(req.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
+
+ media_ids = await edit_video(
+ active_bridge, req.prompt, aspect, req.project_id,
+ video_media_id=req.video_media_id, fps=req.fps,
+ duration=req.duration, start_frame=req.start_frame,
+ end_frame=req.end_frame
+ )
+
+ if not media_ids:
+ raise HTTPException(status_code=500, detail="Failed to submit V2V edit request")
+
+ outputs = []
+ timestamp = int(time.time())
+
+ for i, media_id in enumerate(media_ids):
+ log.info(f"Polling edited video [{i+1}/{len(media_ids)}] ID: {media_id}")
+ if not await poll_status(active_bridge, media_id, req.project_id):
+ continue
+
+ unique_id = uuid.uuid4().hex[:6]
+ filename = f"edit_{timestamp}_{unique_id}_{i+1}.mp4"
+ out_path = os.path.join(OUTPUT_DIR, filename)
+ ensure_temp_dir()
+ temp_path = os.path.join(TEMP_DIR, filename)
+
+ if await download_video(active_bridge, media_id, temp_path):
+ # V2V edited segments might also have watermarks
+ try:
+ from omniflash.watermark import remove_watermark_video
+ remove_watermark_video(temp_path, out_path)
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ except Exception as e:
+ log.warning(f"Watermark removal failed: {e}. Fallback to raw video.")
+ os.replace(temp_path, out_path)
+
+ outputs.append({
+ "media_id": media_id,
+ "filename": filename,
+ "local_path": out_path,
+ "download_url": f"/download/{filename}"
+ })
+
+ cleanup_temp_dir()
+ if not outputs:
+ raise HTTPException(status_code=500, detail="Failed to download edited video(s)")
+
+ if req.download and len(outputs) == 1:
+ return FileResponse(
+ path=outputs[0]["local_path"],
+ filename=outputs[0]["filename"],
+ media_type="video/mp4"
+ )
+
+ return {
+ "success": True,
+ "outputs": outputs
+ }
+
+
+@app.get("/download/{filename}")
+async def api_download_file(filename: str):
+ """Download generated assets from output folder."""
+ file_path = os.path.join(OUTPUT_DIR, filename)
+ if not os.path.exists(file_path):
+ raise HTTPException(status_code=404, detail="Requested file not found")
+
+ # Standardize content types
+ media_type = "application/octet-stream"
+ if filename.endswith(".mp4"):
+ media_type = "video/mp4"
+ elif filename.endswith(".png"):
+ media_type = "image/png"
+ elif filename.endswith(".jpg") or filename.endswith(".jpeg"):
+ media_type = "image/jpeg"
+
+ return FileResponse(path=file_path, filename=filename, media_type=media_type)
+
+
+if __name__ == "__main__":
+ import argparse
+ parser = argparse.ArgumentParser(description="Flow Agent API Server")
+ parser.add_argument("--host", default="127.0.0.1", help="Host address")
+ parser.add_argument("--port", type=int, default=8000, help="Port to run on")
+ parser.add_argument("--ssl", action="store_true", help="Enable self-signed SSL certificate")
+ parser.add_argument("--ssl-certfile", help="SSL certificate file path")
+ parser.add_argument("--ssl-keyfile", help="SSL private key file path")
+ args = parser.parse_args()
+
+ ssl_keyfile = args.ssl_keyfile
+ ssl_certfile = args.ssl_certfile
+
+ if args.ssl and not (ssl_keyfile and ssl_certfile):
+ try:
+ from cryptography import x509
+ from cryptography.x509.oid import NameOID
+ from cryptography.hazmat.primitives import hashes
+ from cryptography.hazmat.primitives.asymmetric import rsa
+ from cryptography.hazmat.primitives import serialization
+ import datetime
+
+ # Generate RSA key
+ key = rsa.generate_private_key(
+ public_exponent=65537,
+ key_size=2048,
+ )
+
+ # Create self-signed cert info
+ subject = issuer = x509.Name([
+ x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"),
+ ])
+ cert = x509.CertificateBuilder().subject_name(
+ subject
+ ).issuer_name(
+ issuer
+ ).public_key(
+ key.public_key()
+ ).serial_number(
+ x509.random_serial_number()
+ ).not_valid_before(
+ datetime.datetime.utcnow()
+ ).not_valid_after(
+ datetime.datetime.utcnow() + datetime.timedelta(days=365)
+ ).add_extension(
+ x509.SubjectAlternativeName([x509.DNSName(u"localhost")]),
+ critical=False,
+ ).sign(key, hashes.SHA256())
+
+ ssl_dir = os.path.join(OUTPUT_DIR, ".ssl")
+ os.makedirs(ssl_dir, exist_ok=True)
+ ssl_keyfile = os.path.join(ssl_dir, "key.pem")
+ ssl_certfile = os.path.join(ssl_dir, "cert.pem")
+
+ with open(ssl_keyfile, "wb") as f:
+ f.write(key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ ))
+ with open(ssl_certfile, "wb") as f:
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+ log.info(f"🔒 Generated temporary self-signed SSL certificate in {ssl_dir}")
+ except ImportError:
+ log.warning("⚠️ cryptography package not found. Cannot auto-generate self-signed SSL cert.")
+ log.warning(" Please install it: pip install cryptography")
+ log.warning(" Falling back to standard HTTP.")
+ args.ssl = False
+
+ import uvicorn
+ uvicorn.run(
+ "cli.api:app",
+ host=args.host,
+ port=args.port,
+ ssl_keyfile=ssl_keyfile if args.ssl or (args.ssl_keyfile and args.ssl_keyfile) else None,
+ ssl_certfile=ssl_certfile if args.ssl or (args.ssl_keyfile and args.ssl_keyfile) else None,
+ )
diff --git a/flow-agent/cli/edit.py b/flow-agent/cli/edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..e249b87c5d3c50c4e92c15260527a513161ae118
--- /dev/null
+++ b/flow-agent/cli/edit.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+"""CLI — Full video editor (V2V with segmentation + merge).
+
+Usage:
+ python -m cli.edit "Make it anime style" -m MEDIA_ID -v video.mp4
+ python -m cli.edit "Cyberpunk neon" -m MEDIA_ID --total-seconds 45 -o output/
+"""
+
+import argparse
+import asyncio
+import logging
+import os
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from omniflash import ExtensionBridge, poll_status, download_video, ASPECTS, DEFAULT_PROJECT
+from omniflash.generators.v2v import edit_video
+from omniflash.generators.common import build_client_context, build_generation_context
+from omniflash.config import ENDPOINTS, CLIENT_CTX, FPS, SEGMENT_DURATION
+
+import random
+
+log = logging.getLogger("cli.edit")
+
+
+def get_video_duration(video_path):
+ """Get video duration in seconds using ffprobe."""
+ try:
+ r = subprocess.run(
+ ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
+ "-of", "csv=p=0", video_path],
+ capture_output=True, text=True
+ )
+ return float(r.stdout.strip())
+ except Exception:
+ try:
+ size = os.path.getsize(video_path)
+ return max(10, size / (1024 * 1024) * 3)
+ except Exception:
+ return None
+
+
+def get_video_fps(video_path):
+ """Get video FPS using ffprobe."""
+ try:
+ r = subprocess.run(
+ ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
+ "-show_entries", "stream=r_frame_rate",
+ "-of", "csv=p=0", video_path],
+ capture_output=True, text=True
+ )
+ fps_str = r.stdout.strip()
+ if "/" in fps_str:
+ num, den = fps_str.split("/")
+ return float(num) / float(den)
+ return float(fps_str)
+ except Exception:
+ return FPS
+
+
+async def edit_segment(bridge, prompt, aspect, project_id, media_id,
+ start_frame, end_frame, segment_num, output_dir):
+ """Edit a single segment and download."""
+ body = {
+ "mediaGenerationContext": build_generation_context("BLOCK_SILENCED_VIDEOS"),
+ "clientContext": build_client_context(project_id),
+ "requests": [{
+ "aspectRatio": aspect,
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
+ "videoModelKey": "abra_edit",
+ "seed": random.randint(1, 9999),
+ "metadata": {},
+ "videoInput": {
+ "mediaId": media_id,
+ "startFrameIndex": start_frame,
+ "endFrameIndex": end_frame,
+ },
+ }],
+ }
+
+ start_sec = start_frame / FPS
+ end_sec = end_frame / FPS
+ log.info("✂️ Segment %d: %.0fs-%.0fs (frames %d-%d)",
+ segment_num, start_sec, end_sec, start_frame, end_frame)
+
+ result = await bridge.api_request(ENDPOINTS["generate_edit"], body)
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ Segment %d failed (%s): %s", segment_num, status, err)
+ return None
+
+ data = result.get("data", {})
+ media_list = data.get("media", [])
+ if not media_list:
+ log.error("❌ No media for segment %d", segment_num)
+ return None
+
+ result_media_id = media_list[0].get("name")
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ Segment %d submitted! media_id=%s, credits=%s",
+ segment_num, result_media_id[:12], credits)
+
+ if not await poll_status(bridge, result_media_id, project_id):
+ return None
+
+ out_path = os.path.join(output_dir, f"segment_{segment_num:03d}.mp4")
+ temp_dir = os.path.join(output_dir, ".temp")
+ os.makedirs(temp_dir, exist_ok=True)
+ temp_path = os.path.join(temp_dir, f"segment_{segment_num:03d}.mp4")
+
+ if await download_video(bridge, result_media_id, temp_path):
+ # Auto-remove watermark
+ try:
+ from omniflash.watermark import remove_watermark_video
+ remove_watermark_video(temp_path, out_path, show_progress=False)
+ os.remove(temp_path)
+ except Exception as e:
+ os.replace(temp_path, out_path)
+ log.warning("⚠️ Watermark removal failed for segment %d: %s", segment_num, e)
+ # Cleanup empty .temp dir
+ try:
+ os.rmdir(temp_dir)
+ except OSError:
+ pass
+ return out_path
+ return None
+
+
+async def run(args):
+ aspect = ASPECTS.get(args.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
+
+ total_seconds = args.total_seconds
+ fps = FPS
+
+ if args.video_file and os.path.exists(args.video_file):
+ if not total_seconds:
+ total_seconds = get_video_duration(args.video_file)
+ log.info("📹 Video: %s (%.1fs)", args.video_file, total_seconds or 0)
+ fps = get_video_fps(args.video_file)
+ log.info("📹 FPS: %.1f", fps)
+
+ if not total_seconds:
+ log.error("❌ Can't determine video duration. Use --total-seconds")
+ return
+
+ os.makedirs(args.output, exist_ok=True)
+
+ # Calculate segments
+ segments = []
+ current = 0
+ seg_num = 1
+ while current < total_seconds:
+ start_frame = int(current * fps)
+ end_frame = int(min(current + SEGMENT_DURATION, total_seconds) * fps)
+ if end_frame <= start_frame:
+ break
+ segments.append((seg_num, start_frame, end_frame))
+ current += SEGMENT_DURATION
+ seg_num += 1
+
+ log.info("📋 Total: %.1fs → %d segments of %ds each",
+ total_seconds, len(segments), SEGMENT_DURATION)
+ log.info("─" * 50)
+
+ bridge = ExtensionBridge()
+ await bridge.start()
+ if not await bridge.wait_for_extension(timeout=30):
+ return
+
+ # Process segments (max 5 concurrent)
+ semaphore = asyncio.Semaphore(5)
+ results = [None] * len(segments)
+
+ async def process_segment(idx, seg_num, start_frame, end_frame):
+ async with semaphore:
+ out = await edit_segment(
+ bridge, args.prompt, aspect, args.project_id,
+ args.media_id, start_frame, end_frame, seg_num, args.output
+ )
+ results[idx] = out
+
+ tasks = [
+ asyncio.create_task(process_segment(idx, sn, sf, ef))
+ for idx, (sn, sf, ef) in enumerate(segments)
+ ]
+ await asyncio.gather(*tasks)
+ await bridge.close()
+
+ saved = [r for r in results if r]
+
+ log.info("─" * 50)
+ log.info("🎉 Done! %d/%d segments saved to %s/", len(saved), len(segments), args.output)
+ for f in saved:
+ log.info(" ✅ %s", os.path.basename(f))
+
+ # Merge with ffmpeg
+ if len(saved) > 1 and args.merge:
+ merge_path = os.path.join(args.output, "merged_output.mp4")
+ log.info("🔗 Merging %d segments...", len(saved))
+ try:
+ concat_file = os.path.join(args.output, "concat.txt")
+ with open(concat_file, "w") as f:
+ for s in saved:
+ f.write(f"file '{os.path.abspath(s)}'\n")
+ subprocess.run([
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
+ "-i", concat_file, "-c", "copy", merge_path
+ ], capture_output=True)
+ os.remove(concat_file)
+ # Auto-remove watermark from merged output
+ try:
+ from omniflash.watermark import remove_watermark_video
+ clean_path = remove_watermark_video(merge_path, show_progress=False)
+ os.replace(clean_path, merge_path)
+ except Exception:
+ pass
+ log.info("✅ Merged: %s", merge_path)
+ except Exception as e:
+ log.warning("⚠️ Merge failed (ffmpeg needed): %s", e)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Omni Flash — Full Video Editor")
+ parser.add_argument("prompt", help="Edit prompt")
+ parser.add_argument("--media-id", "-m", required=True, help="Flow media ID")
+ parser.add_argument("--video-file", "-v", help="Local video (for duration/fps)")
+ parser.add_argument("--total-seconds", "-t", type=float)
+ parser.add_argument("--output", "-o", default="output", help="Output directory")
+ parser.add_argument("--aspect", "-a", choices=["portrait", "landscape"], default="portrait")
+ parser.add_argument("--merge", action="store_true")
+ parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT)
+ args = parser.parse_args()
+ asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow-agent/cli/generate.py b/flow-agent/cli/generate.py
new file mode 100644
index 0000000000000000000000000000000000000000..7de5c5bfe8f4437bb6eac7c6698ea41db539bd91
--- /dev/null
+++ b/flow-agent/cli/generate.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""CLI — Generate video from text prompt (T2V) or edit existing video (V2V).
+
+Usage:
+ python -m cli.generate "A dragon breathing fire"
+ python -m cli.generate "A dragon breathing fire" --aspect landscape -o dragon.mp4
+ python -m cli.generate "Make it anime" --edit MEDIA_ID
+"""
+
+import argparse
+import asyncio
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from omniflash import (
+ ExtensionBridge, generate_video, edit_video,
+ poll_status, download_video, ASPECTS, DEFAULT_PROJECT,
+)
+from omniflash.generators.i2v import upload_image, generate_video_i2v, generate_video_fl, generate_video_r2v
+
+
+async def run(args):
+ aspect = ASPECTS.get(args.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
+
+ bridge = ExtensionBridge()
+ await bridge.start()
+
+ if not await bridge.wait_for_extension(timeout=30):
+ return
+
+ # Auto-upload local image files
+ async def resolve_image(path_or_id):
+ if os.path.exists(path_or_id):
+ mid = await upload_image(bridge, path_or_id)
+ if mid:
+ print(f"📤 Uploaded: {path_or_id} → {mid[:12]}...")
+ return mid
+ return path_or_id
+
+ if args.start and args.end:
+ # First+Last frame mode
+ start_id = await resolve_image(args.start)
+ end_id = await resolve_image(args.end)
+ if not start_id or not end_id:
+ await bridge.close()
+ return
+ media_ids = await generate_video_fl(bridge, args.prompt, aspect, args.project_id,
+ start_image_id=start_id, end_image_id=end_id,
+ duration=args.duration)
+ elif args.start:
+ # I2V mode (start image only)
+ start_id = await resolve_image(args.start)
+ if not start_id:
+ await bridge.close()
+ return
+ media_ids = await generate_video_i2v(bridge, args.prompt, aspect, args.project_id,
+ image_media_id=start_id, duration=args.duration)
+ elif args.ref:
+ # Reference images mode
+ ref_ids = []
+ for r in args.ref:
+ mid = await resolve_image(r)
+ if mid:
+ ref_ids.append(mid)
+ if not ref_ids:
+ await bridge.close()
+ return
+ media_ids = await generate_video_r2v(bridge, args.prompt, aspect, args.project_id,
+ ref_media_ids=ref_ids, duration=args.duration)
+ elif args.edit:
+ media_ids = await edit_video(bridge, args.prompt, aspect, args.project_id,
+ video_media_id=args.edit, duration=args.duration)
+ else:
+ media_ids = await generate_video(bridge, args.prompt, aspect, args.project_id,
+ duration=args.duration, count=args.count)
+
+ if not media_ids:
+ await bridge.close()
+ return
+
+ for i, media_id in enumerate(media_ids):
+ label = f"[{i+1}/{len(media_ids)}] " if len(media_ids) > 1 else ""
+ print(f"{label}Polling {media_id[:12]}...")
+ if not await poll_status(bridge, media_id, args.project_id):
+ continue
+
+ if len(media_ids) == 1:
+ out_path = args.output
+ else:
+ base, ext = os.path.splitext(args.output)
+ out_path = f"{base}_{i+1}{ext}"
+
+ # Setup temp dir for download
+ out_dir = os.path.dirname(out_path) or "."
+ temp_dir = os.path.join(out_dir, ".temp")
+ os.makedirs(temp_dir, exist_ok=True)
+ temp_path = os.path.join(temp_dir, os.path.basename(out_path))
+
+ if await download_video(bridge, media_id, temp_path):
+ # Auto-remove watermark unless --no-clean
+ if not args.no_clean:
+ try:
+ from omniflash.watermark import remove_watermark_video
+ remove_watermark_video(temp_path, out_path)
+ os.remove(temp_path)
+ print(f"🧹 Watermark removed!")
+ except Exception as e:
+ # Fallback: move temp to output as-is
+ os.replace(temp_path, out_path)
+ print(f"⚠️ Watermark removal failed: {e}")
+ else:
+ os.replace(temp_path, out_path)
+
+ # Cleanup empty .temp dir
+ try:
+ os.rmdir(temp_dir)
+ except OSError:
+ pass
+
+ print(f"🎉 Done! {out_path}")
+
+ await bridge.close()
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Omni Flash — Video Generator")
+ parser.add_argument("prompt", help="Text prompt for video")
+ parser.add_argument("--output", "-o", default="omni_output.mp4", help="Output file")
+ parser.add_argument("--aspect", "-a", choices=["portrait", "landscape"], default="portrait")
+ parser.add_argument("--duration", "-d", type=int, choices=[4, 6, 8, 10], default=10)
+ parser.add_argument("--count", "-c", type=int, choices=[1, 2, 3, 4], default=1)
+ parser.add_argument("--edit", "-e", metavar="MEDIA_ID",
+ help="Edit existing video (V2V mode)")
+ parser.add_argument("--start", "-s", metavar="IMAGE",
+ help="Start frame image (file path or media_id)")
+ parser.add_argument("--end", metavar="IMAGE",
+ help="End frame image (use with --start for FL mode)")
+ parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE",
+ help="Reference images for R2V mode")
+ parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT)
+ parser.add_argument("--no-clean", action="store_true",
+ help="Skip automatic watermark removal")
+ args = parser.parse_args()
+ asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow-agent/cli/image.py b/flow-agent/cli/image.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1386f200167767266f0a992e0b56bc319bd98b7
--- /dev/null
+++ b/flow-agent/cli/image.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""CLI — Generate image from text prompt (T2I).
+
+Usage:
+ python -m cli.image "A cat wearing sunglasses on a beach"
+ python -m cli.image "Dragon in cyberpunk city" --aspect landscape --count 4
+ python -m cli.image "Logo design" --aspect square -o logo.png
+"""
+
+import argparse
+import asyncio
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from omniflash import ExtensionBridge, DEFAULT_PROJECT
+from omniflash.generators.t2i import generate_image, download_image, IMAGE_ASPECTS
+
+
+async def run(args):
+ aspect = args.aspect
+
+ bridge = ExtensionBridge()
+ await bridge.start()
+
+ if not await bridge.wait_for_extension(timeout=30):
+ return
+
+ # Handle ref images: auto-upload local files
+ ref_ids = []
+ if args.ref:
+ from omniflash.generators.i2v import upload_image
+ for ref in args.ref:
+ if os.path.exists(ref):
+ print(f"📤 Uploading reference: {ref}")
+ mid = await upload_image(bridge, ref)
+ if mid:
+ ref_ids.append(mid)
+ print(f" media_id={mid[:12]}...")
+ else:
+ # Assume it's already a media_id
+ ref_ids.append(ref)
+
+ results = await generate_image(
+ bridge, args.prompt, aspect, args.project_id,
+ count=args.count, ref_media_ids=ref_ids or None
+ )
+
+ if not results:
+ await bridge.close()
+ return
+
+ os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
+
+ for i, r in enumerate(results):
+ if not r.get("image_url"):
+ print(f"⚠️ Image {i+1}: no URL")
+ continue
+
+ if len(results) == 1:
+ out_path = args.output
+ else:
+ base, ext = os.path.splitext(args.output)
+ out_path = f"{base}_{i+1}{ext}"
+
+ if await download_image(bridge, r["image_url"], out_path):
+ print(f"🎉 Done! {out_path}")
+
+ await bridge.close()
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Flow Agent — Image Generator")
+ parser.add_argument("prompt", help="Text prompt for image")
+ parser.add_argument("--output", "-o", default="output/image.png", help="Output file")
+ parser.add_argument("--aspect", "-a",
+ choices=list(IMAGE_ASPECTS.keys()),
+ default="portrait",
+ help="Aspect ratio")
+ parser.add_argument("--count", "-c", type=int, choices=[1, 2, 3, 4], default=1,
+ help="Generate 1-4 variations")
+ parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE",
+ help="Reference image(s): file path or media_id")
+ parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT)
+ args = parser.parse_args()
+ asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow-agent/cli/sniff.py b/flow-agent/cli/sniff.py
new file mode 100644
index 0000000000000000000000000000000000000000..aead6ffb35c18432c968de749c300208502b2108
--- /dev/null
+++ b/flow-agent/cli/sniff.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""CLI — API request sniffer.
+
+Captures all Flow UI API requests for endpoint discovery.
+
+Usage:
+ python -m cli.sniff
+ python -m cli.sniff --save sniffed.json
+"""
+
+import asyncio
+import argparse
+import json
+import logging
+import os
+import sys
+from http.server import HTTPServer, BaseHTTPRequestHandler
+import threading
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+for _pkg in ["websockets"]:
+ try:
+ __import__(_pkg)
+ except ImportError:
+ os.system(f"{sys.executable} -m pip install {_pkg} -q")
+
+import websockets
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
+log = logging.getLogger("cli.sniff")
+
+all_requests = []
+
+IGNORE = {"batchLog", "frontendEvents", "fetchUserRecommendations", "flowAgent/applets",
+ "savedSharedApplets", "models/statuses"}
+
+
+def make_handler(save_file):
+ class Handler(BaseHTTPRequestHandler):
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length)) if length else {}
+
+ if body.get("type") == "sniffed_video_request":
+ url = body.get("url", "")
+ if not any(n in url for n in IGNORE):
+ method = body.get("method", "?")
+ payload = body.get("payload", "")
+
+ log.info("─" * 60)
+ log.info("🔍 %s %s", method, url.split("?")[0])
+ if payload and payload != "(empty)":
+ try:
+ parsed = json.loads(payload)
+ log.info(" %s", json.dumps(parsed, indent=2)[:2000])
+ except (json.JSONDecodeError, TypeError):
+ log.info(" %s", str(payload)[:1000])
+
+ entry = {
+ "url": url,
+ "method": method,
+ "payload": payload,
+ "timestamp": body.get("timestamp"),
+ }
+ all_requests.append(entry)
+
+ if save_file:
+ with open(save_file, "w") as f:
+ json.dump(all_requests, f, indent=2)
+
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(b'{"ok":true}')
+
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "POST")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.end_headers()
+
+ def log_message(self, *a):
+ pass
+
+ return Handler
+
+
+async def on_connect(ws):
+ log.info("✅ Extension connected!")
+ async for raw in ws:
+ data = json.loads(raw)
+ if data.get("type") == "token_captured":
+ log.info("🔑 Token captured")
+
+
+async def run(args):
+ Handler = make_handler(args.save)
+ srv = HTTPServer(("127.0.0.1", args.port), Handler)
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+
+ log.info("⚡ WS server on ws://127.0.0.1:%d", args.ws_port)
+ log.info("⚡ HTTP callback on http://127.0.0.1:%d", args.port)
+ if args.save:
+ log.info("💾 Saving to: %s", args.save)
+ log.info("👉 Open Flow UI and perform any action...")
+ log.info("─" * 60)
+
+ async with websockets.serve(on_connect, "127.0.0.1", args.ws_port):
+ await asyncio.Future()
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Flow API Sniffer")
+ parser.add_argument("--save", "-s", help="Save to JSON file")
+ parser.add_argument("--port", type=int, default=8100)
+ parser.add_argument("--ws-port", type=int, default=9222)
+ args = parser.parse_args()
+ asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow-agent/cli/upload.py b/flow-agent/cli/upload.py
new file mode 100644
index 0000000000000000000000000000000000000000..3e6f54e37d6b6ae2bf668c42e9d4ee0bf539f50f
--- /dev/null
+++ b/flow-agent/cli/upload.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""CLI — Upload video or batch upload directory.
+
+Usage:
+ python -m cli.upload video.mp4
+ python -m cli.upload chunks/ --batch
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from omniflash.upload import upload_video
+
+
+async def run(args):
+ if args.batch or os.path.isdir(args.path):
+ # Batch upload all mp4 in directory
+ directory = args.path
+ chunks = sorted([f for f in os.listdir(directory) if f.endswith(".mp4")])
+ if not chunks:
+ print(f"❌ No .mp4 files found in {directory}")
+ return
+ print(f"📁 Found {len(chunks)} videos in {directory}")
+ for i, chunk in enumerate(chunks, 1):
+ path = os.path.join(directory, chunk)
+ print(f"\n{'─' * 50}")
+ print(f"[{i}/{len(chunks)}] {chunk}")
+ try:
+ await upload_video(path, args.project_id)
+ except Exception as e:
+ print(f"❌ {chunk} failed: {e}")
+ else:
+ # Single file upload
+ result = await upload_video(args.path, args.project_id)
+ print(json.dumps(result, indent=2))
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Omni Flash — Upload Video")
+ parser.add_argument("path", help="Video file or directory of videos")
+ parser.add_argument("--batch", "-b", action="store_true",
+ help="Batch upload all .mp4 in directory")
+ parser.add_argument("--project-id", "-p",
+ default="ff92d5cc-8a03-41d2-b59e-e0774d17bcf6")
+ args = parser.parse_args()
+ asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow-agent/error.md b/flow-agent/error.md
new file mode 100644
index 0000000000000000000000000000000000000000..557e9f36829bd7ef80caee589ae56cc694660543
--- /dev/null
+++ b/flow-agent/error.md
@@ -0,0 +1,74 @@
+# 🛠️ Flow Agent API Troubleshooting Guide (error.md)
+
+This document contains standard error scenarios and how to resolve them quickly.
+
+---
+
+## 1. Error: `OSError: [Errno 48] Address already in use`
+* **Symptom**: The server fails to start and exits with: `OSError: [Errno 48] Address already in use` (typically for port `8000`, `8100`, or `9222`).
+* **Cause**: Another instance of the API server or a background flow-agent process is already running and occupying the port.
+* **Resolution**:
+ Run the following commands in your terminal to find and kill the process:
+ ```bash
+ # Clear API server port (8000)
+ kill -9 $(lsof -t -i:8000) 2>/dev/null || true
+
+ # Clear Extension bridge HTTP callback port (8100)
+ kill -9 $(lsof -t -i:8100) 2>/dev/null || true
+
+ # Clear Extension bridge WebSocket port (9222)
+ kill -9 $(lsof -t -i:9222) 2>/dev/null || true
+ ```
+
+---
+
+## 2. Error: `Internal Server Error (500) - RuntimeError: curl failed`
+* **Symptom**: Step `Uploading Video to Flow` fails with Status Code `500` and the server console prints: `RuntimeError: curl failed: ...`
+* **Cause**: Your terminal session has sandbox/proxy environment variables active (`http_proxy`, `https_proxy`, `HTTP_PROXY`, or `HTTPS_PROXY` might be set by the AI agent sandbox). This causes `curl` to route all Google Cloud Storage uploads through a proxy that blocks the connection.
+* **Resolution**:
+ Clear the proxy variables in your terminal window before starting the server and running the test script:
+ ```bash
+ # 1. Unset the proxy variables
+ unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
+
+ # 2. Restart the API server in this terminal
+ venv/bin/python -m cli.api --port 8000
+ ```
+ *(Make sure to also run `unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY` in your testing/client terminal window as well).*
+
+---
+
+## 3. Error: `Google Flow extension is not connected or unauthorized`
+* **Symptom**: `/health` returns `has_flow_key: false` and generation calls return: `"Google Flow extension is not connected or unauthorized. Make sure Google Flow tab is open in Chrome."`
+* **Cause**: The Extension Bridge WS is connected, but the Chrome extension is unable to capture the auth token (`flowKey`) because Google Flow is either not open, has gone idle, or your Google account has logged out.
+* **Resolution**:
+ 1. Open Chrome.
+ 2. Make sure you are logged into your Google account at **[labs.google/fx/tools/flow](https://labs.google/fx/tools/flow)**.
+ 3. Reload the page. The extension icon in your extension bar should show a green indicator.
+ 4. Once logged in, the extension will automatically push the token to the server and heal the state.
+
+---
+
+## 4. Error: `Failed (0): TIMEOUT`
+* **Symptom**: Request fails after 90 seconds with `TIMEOUT`.
+* **Cause**: Google Flow took too long to respond, or there is an active **reCAPTCHA challenge** popped up on your Chrome browser that requires manual verification.
+* **Resolution**:
+ 1. Open Chrome and inspect the Google Flow tab.
+ 2. If a reCAPTCHA prompt is present, solve it.
+ 3. Reload the tab to refresh the connection, wait 5 seconds, and try your request again.
+
+---
+
+## 5. Error: `zsh: no such file or directory: venv/bin/python`
+* **Symptom**: Running server or test script returns: `zsh: no such file or directory: venv/bin/python` or similar file errors.
+* **Cause**: The command is run from the parent workspace folder (`N8N-Agent`) instead of the cloned `flow-agent` project folder where the virtual environment (`venv`) resides.
+* **Resolution**:
+ Always change your directory to the `flow-agent` folder before running commands, or use the absolute paths:
+ ```bash
+ # Go to the correct directory first
+ cd /path/to/flow-agent
+
+ # Then run the command
+ venv/bin/python -m cli.api --port 8000
+ ```
+
diff --git a/flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 b/flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1
new file mode 100644
index 0000000000000000000000000000000000000000..47c6d85305240268eedbd1dad1dc9427bfd7d3a1
Binary files /dev/null and b/flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 differ
diff --git a/flow-agent/extension/background.js b/flow-agent/extension/background.js
new file mode 100644
index 0000000000000000000000000000000000000000..e6ac8238bbcd97168b22c1e85285b4c9dc2915be
--- /dev/null
+++ b/flow-agent/extension/background.js
@@ -0,0 +1,879 @@
+/**
+ * Flow Agent — Chrome Extension Background Service Worker
+ *
+ * Connects to local Python agent via WebSocket (agent runs WS server).
+ * Captures bearer token, solves reCAPTCHA, proxies API calls through browser.
+ */
+
+const AGENT_WS_URL = 'ws://127.0.0.1:9222';
+// NOTE: This is a browser-restricted public API key — safe to ship in extension bundles.
+const API_KEY = 'AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY';
+
+let ws = null;
+let flowKey = null;
+let callbackSecret = null; // Auth secret for HTTP callback, received from server on WS connect
+let state = 'off'; // off | idle | running
+let manualDisconnect = false;
+let metrics = {
+ tokenCapturedAt: null,
+ requestCount: 0, // captcha-consuming requests only (gen image/video/upscale)
+ successCount: 0,
+ failedCount: 0,
+ lastError: null,
+};
+
+// ─── URL → Log Type Classifier ─────────────────────────────
+
+// Visible log types — only these appear in the request log
+const _VISIBLE_TYPES = new Set(['GEN_IMG', 'GEN_VID', 'GEN_VID_REF', 'UPSCALE', 'TRACKING', 'URL_REFRESH']);
+
+function _classifyApiUrl(url) {
+ if (url.includes('uploadImage')) return 'UPLOAD';
+ if (url.includes('batchGenerateImages')) return 'GEN_IMG';
+ if (url.includes('UpsampleVideo')) return 'UPSCALE';
+ if (url.includes('ReferenceImages')) return 'GEN_VID_REF';
+ if (url.includes('batchAsyncGenerateVideo')) return 'GEN_VID';
+ if (url.includes('batchCheckAsync')) return 'POLL';
+ if (url.includes('upsampleImage')) return 'UPS_IMG';
+ if (url.includes('/media/')) return 'MEDIA';
+ if (url.includes('/credits')) return 'CREDITS';
+ return 'API';
+}
+
+// ─── Request Log ────────────────────────────────────────────
+
+let requestLog = [];
+
+function addRequestLog(entry) {
+ requestLog.unshift(entry);
+ if (requestLog.length > 100) requestLog.pop();
+ broadcastRequestLog();
+}
+
+function updateRequestLog(id, updates) {
+ const entry = requestLog.find((e) => e.id === id);
+ if (entry) Object.assign(entry, updates);
+ broadcastRequestLog();
+}
+
+function broadcastRequestLog() {
+ chrome.runtime.sendMessage({ type: 'REQUEST_LOG_UPDATE', log: requestLog }).catch(() => {});
+}
+
+// ─── Startup ────────────────────────────────────────────────
+
+chrome.runtime.onInstalled.addListener(init);
+chrome.runtime.onStartup.addListener(init);
+chrome.alarms.onAlarm.addListener(async (alarm) => {
+ if (alarm.name === 'reconnect') connectToAgent();
+ if (alarm.name === 'keepAlive') keepAlive();
+ if (alarm.name === 'token-refresh') {
+ await captureTokenFromFlowTab();
+ }
+});
+
+async function init() {
+ const data = await chrome.storage.local.get(['flowKey', 'metrics', 'callbackSecret']);
+ if (data.flowKey) flowKey = data.flowKey;
+ if (data.metrics) Object.assign(metrics, data.metrics);
+ if (data.callbackSecret) callbackSecret = data.callbackSecret;
+ connectToAgent();
+ chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
+}
+
+// ─── Token Capture ──────────────────────────────────────────
+
+chrome.webRequest.onBeforeSendHeaders.addListener(
+ (details) => {
+ if (!details?.requestHeaders?.length) return;
+ const authHeader = details.requestHeaders.find(
+ (h) => h.name?.toLowerCase() === 'authorization',
+ );
+ const value = authHeader?.value || '';
+ if (!value.startsWith('Bearer ya29.')) return;
+
+ const token = value.replace(/^Bearer\s+/i, '').trim();
+ if (!token) return;
+
+ // Always update — even if same token string, refresh the timestamp
+ flowKey = token;
+ metrics.tokenCapturedAt = Date.now();
+ chrome.storage.local.set({ flowKey, metrics });
+ console.log('[Flow Agent] Bearer token captured');
+
+ // Notify agent
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
+ }
+ },
+ { urls: ['https://aisandbox-pa.googleapis.com/*', 'https://labs.google/*'] },
+ ['requestHeaders', 'extraHeaders'],
+);
+
+let _openingFlowTab = false;
+
+async function captureTokenFromFlowTab() {
+ const tabs = await chrome.tabs.query({
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
+ });
+ if (!tabs.length) {
+ if (_openingFlowTab) {
+ console.log('[Flow Agent] Flow tab already opening, skipping');
+ return;
+ }
+ _openingFlowTab = true;
+ try {
+ console.log('[Flow Agent] No Flow tab found — opening one in background');
+ await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: false });
+ await sleep(3000);
+ const retryTabs = await chrome.tabs.query({
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
+ });
+ if (!retryTabs.length) {
+ console.log('[Flow Agent] Flow tab not ready yet after open');
+ return;
+ }
+ await chrome.scripting.executeScript({
+ target: { tabId: retryTabs[0].id },
+ files: ['content.js'],
+ });
+ console.log('[Flow Agent] Token refresh triggered on newly opened Flow tab');
+ } catch (e) {
+ console.error('[Flow Agent] Token refresh failed after opening tab:', e);
+ } finally {
+ _openingFlowTab = false;
+ }
+ return;
+ }
+ try {
+ await chrome.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ files: ['content.js'],
+ });
+ console.log('[Flow Agent] Token refresh triggered on Flow tab');
+ } catch (e) {
+ console.error('[Flow Agent] Token refresh failed:', e);
+ }
+}
+
+// ─── WebSocket to Agent ─────────────────────────────────────
+
+function connectToAgent() {
+ if (manualDisconnect) return;
+ if (ws?.readyState === WebSocket.CONNECTING) return;
+ if (ws?.readyState === WebSocket.OPEN) return;
+
+ try {
+ ws = new WebSocket(AGENT_WS_URL);
+ } catch (e) {
+ console.error('[Flow Agent] WS connect error:', e);
+ scheduleReconnect();
+ return;
+ }
+
+ ws.onopen = () => {
+ console.log('[Flow Agent] Connected to agent');
+ chrome.alarms.clear('reconnect');
+ setState('idle');
+
+ // Token refresh alarm — 45 min gives buffer before ~60 min expiry
+ chrome.alarms.create('token-refresh', { periodInMinutes: 45 });
+
+ // Send current state + resend token if we have one
+ ws.send(JSON.stringify({
+ type: 'extension_ready',
+ flowKeyPresent: !!flowKey,
+ tokenAge: flowKey && metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
+ }));
+ if (flowKey) {
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
+ }
+ };
+
+ ws.onmessage = async ({ data }) => {
+ try {
+ const msg = JSON.parse(data);
+
+ if (msg.method === 'api_request') {
+ await handleApiRequest(msg);
+ } else if (msg.method === 'trpc_request') {
+ await handleTrpcRequest(msg);
+ } else if (msg.method === 'upload_video') {
+ await handleUploadVideo(msg);
+ } else if (msg.method === 'solve_captcha') {
+ await handleSolveCaptcha(msg);
+ } else if (msg.method === 'get_status') {
+ sendToAgent({
+ id: msg.id,
+ result: {
+ state,
+ flowKeyPresent: !!flowKey,
+ manualDisconnect,
+ tokenAge: metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
+ metrics,
+ },
+ });
+ } else if (msg.method === 'open_flow_tab') {
+ // Python bridge asks us to open/focus a Flow tab
+ console.log('[Flow Agent] Agent requested: open Flow tab');
+ const tabs = await chrome.tabs.query({
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
+ });
+ if (tabs.length) {
+ // Tab exists — refresh it to trigger fresh API calls → token capture
+ await chrome.tabs.reload(tabs[0].id);
+ console.log('[Flow Agent] Refreshed existing Flow tab');
+ } else {
+ // No tab — open one (active so it loads properly)
+ await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: true });
+ console.log('[Flow Agent] Opened new Flow tab');
+ }
+ // Wait for page to load and make API calls that trigger token capture
+ await sleep(5000);
+ // If token was captured by webRequest during page load, send it
+ if (flowKey && ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
+ console.log('[Flow Agent] Sent stored token after tab open');
+ } else {
+ // Try reading from storage as fallback
+ const data = await chrome.storage.local.get(['flowKey']);
+ if (data.flowKey) {
+ flowKey = data.flowKey;
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
+ console.log('[Flow Agent] Sent token from storage after tab open');
+ }
+ }
+ }
+ } else if (msg.method === 'refresh_flow_tab') {
+ // Python bridge asks us to refresh token
+ console.log('[Flow Agent] Agent requested: refresh token');
+ await captureTokenFromFlowTab();
+ await sleep(3000);
+ // Actively send token if we have one
+ if (flowKey && ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
+ console.log('[Flow Agent] Sent token after refresh');
+ } else {
+ const data = await chrome.storage.local.get(['flowKey']);
+ if (data.flowKey) {
+ flowKey = data.flowKey;
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
+ console.log('[Flow Agent] Sent token from storage after refresh');
+ }
+ }
+ }
+ } else if (msg.type === 'callback_secret') {
+ callbackSecret = msg.secret;
+ chrome.storage.local.set({ callbackSecret: msg.secret });
+ console.log('[Flow Agent] Received callback secret');
+ } else if (msg.type === 'pong') {
+ // keepalive response
+ }
+ } catch (e) {
+ console.error('[Flow Agent] Message error:', e);
+ }
+ };
+
+ ws.onclose = () => {
+ setState('off');
+ chrome.alarms.clear('token-refresh');
+ if (!manualDisconnect) scheduleReconnect();
+ };
+
+ ws.onerror = (e) => {
+ console.error('[Flow Agent] WS error:', e);
+ metrics.lastError = 'WS_ERROR';
+ chrome.storage.local.set({ metrics });
+ };
+}
+
+function scheduleReconnect() {
+ chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s
+}
+
+function keepAlive() {
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'ping' }));
+ } else {
+ connectToAgent();
+ }
+}
+
+function sendToAgent(msg) {
+ // API responses (with msg.id) go via HTTP — immune to WS disconnect
+ if (msg.id) {
+ fetch('http://127.0.0.1:8100/api/ext/callback', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(msg),
+ }).catch(() => {
+ // HTTP failed — fallback to WS
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
+ });
+ return;
+ }
+ // Non-response messages (ping, status) or no secret yet — use WS
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(msg));
+ }
+}
+
+// ─── reCAPTCHA Solving ──────────────────────────────────────
+
+async function requestCaptchaFromTab(tabId, requestId, pageAction) {
+ try {
+ return await chrome.tabs.sendMessage(tabId, {
+ type: 'GET_CAPTCHA',
+ requestId,
+ pageAction,
+ });
+ } catch (error) {
+ const msg = error?.message || '';
+ const shouldInject =
+ msg.includes('Receiving end does not exist') ||
+ msg.includes('Could not establish connection');
+ if (!shouldInject) throw error;
+
+ // Inject content script and retry
+ await chrome.scripting.executeScript({
+ target: { tabId },
+ files: ['content.js'],
+ });
+ await sleep(200);
+ return await chrome.tabs.sendMessage(tabId, {
+ type: 'GET_CAPTCHA',
+ requestId,
+ pageAction,
+ });
+ }
+}
+
+async function solveCaptcha(requestId, captchaAction) {
+ const tabs = await chrome.tabs.query({
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
+ });
+
+ if (!tabs.length) {
+ // Auto-open Flow tab and wait briefly before returning error
+ try {
+ await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: false });
+ await sleep(3000);
+ // Retry tab query after opening
+ const retryTabs = await chrome.tabs.query({
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
+ });
+ if (!retryTabs.length) return { error: 'NO_FLOW_TAB' };
+ const resp = await Promise.race([
+ requestCaptchaFromTab(retryTabs[0].id, requestId, captchaAction),
+ new Promise((_, rej) => setTimeout(() => rej(new Error('CAPTCHA_TIMEOUT')), 30000)),
+ ]);
+ return resp;
+ } catch (e) {
+ return { error: e.message || 'NO_FLOW_TAB' };
+ }
+ }
+
+ try {
+ const resp = await Promise.race([
+ requestCaptchaFromTab(tabs[0].id, requestId, captchaAction),
+ new Promise((_, rej) => setTimeout(() => rej(new Error('CAPTCHA_TIMEOUT')), 30000)),
+ ]);
+ return resp;
+ } catch (e) {
+ return { error: e.message };
+ }
+}
+
+async function handleSolveCaptcha(msg) {
+ const { id, params } = msg;
+ const result = await solveCaptcha(id, params?.captchaAction || 'VIDEO_GENERATION');
+
+ // Standalone captcha solve counts as captcha-consuming
+ metrics.requestCount++;
+ if (result?.token) {
+ metrics.successCount++;
+ } else {
+ metrics.failedCount++;
+ metrics.lastError = result?.error || 'NO_TOKEN';
+ }
+ chrome.storage.local.set({ metrics });
+
+ sendToAgent({ id, result });
+}
+
+// ─── API Request Proxy ──────────────────────────────────────
+
+async function handleTrpcRequest(msg) {
+ const { id, params } = msg;
+ const { url, method = 'POST', headers = {}, body } = params;
+
+ if (!url || !url.startsWith('https://labs.google/')) {
+ sendToAgent({ id, error: 'INVALID_TRPC_URL' });
+ return;
+ }
+
+ setState('running');
+ // TRPC calls don't consume captcha — don't count in metrics
+
+ const logId = id;
+ const logType = url.includes('createProject') ? 'CREATE_PROJECT' : 'TRPC';
+ // TRPC calls are silent — don't show in request log
+
+ const fetchHeaders = { 'Content-Type': 'application/json', ...headers };
+ if (flowKey) {
+ fetchHeaders['authorization'] = `Bearer ${flowKey}`;
+ }
+
+ try {
+ const resp = await fetch(url, {
+ method,
+ headers: fetchHeaders,
+ body: body ? JSON.stringify(body) : undefined,
+ credentials: 'include',
+ });
+ const data = await resp.json();
+ chrome.storage.local.set({ metrics });
+ updateRequestLog(logId, { status: 'success' });
+ sendToAgent({ id, status: resp.status, data });
+ } catch (e) {
+ console.error('[Flow Agent] tRPC request failed:', e);
+ chrome.storage.local.set({ metrics });
+ updateRequestLog(logId, { status: 'failed', error: e.message || 'TRPC_FETCH_FAILED' });
+ sendToAgent({ id, error: e.message || 'TRPC_FETCH_FAILED' });
+ } finally {
+ setState('idle');
+ }
+}
+
+
+async function handleUploadVideo(msg) {
+ const { id, params } = msg;
+ const { videoBase64, projectId, videoSize } = params;
+
+ try {
+ const tabs = await chrome.tabs.query({ url: '*://labs.google/*' });
+ if (!tabs.length) {
+ sendToAgent({ id, error: 'NO_FLOW_TAB' });
+ return;
+ }
+
+ const size = videoSize || (videoBase64 ? Math.floor(videoBase64.length * 3 / 4) : 0);
+
+ // Get session URL via page context XHR (needs session cookies)
+ const startResults = await chrome.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ world: 'MAIN',
+ func: (projId, sz) => {
+ return new Promise((resolve) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/fx/api/upload-video?action=start');
+ xhr.setRequestHeader('X-Upload-Project-Id', projId);
+ xhr.setRequestHeader('X-Upload-Content-Type', 'video/mp4');
+ xhr.setRequestHeader('X-Upload-Content-Length', sz.toString());
+ xhr.withCredentials = true;
+ xhr.onload = () => {
+ let data;
+ try { data = JSON.parse(xhr.responseText); } catch { data = {}; }
+ resolve({
+ sessionUrl: data.sessionUrl || xhr.getResponseHeader('X-Upload-Session-Url') || '',
+ status: xhr.status,
+ });
+ };
+ xhr.onerror = () => resolve({ error: 'POST_FAILED' });
+ xhr.send();
+ });
+ },
+ args: [projectId, size],
+ });
+
+ const step1 = startResults?.[0]?.result;
+ if (!step1 || step1.error || !step1.sessionUrl) {
+ sendToAgent({ id, error: step1?.error || 'NO_SESSION_URL' });
+ return;
+ }
+
+ // Return sessionUrl + token — caller handles PUT
+ sendToAgent({
+ id,
+ result: {
+ sessionUrl: step1.sessionUrl,
+ token: flowKey || '',
+ },
+ });
+ } catch (e) {
+ sendToAgent({ id, error: `UPLOAD_ERROR: ${e.message}` });
+ }
+}
+
+async function handleApiRequest(msg) {
+ const { id, params } = msg;
+ const { url, method, headers, body, captchaAction } = params;
+
+ if (!url) {
+ sendToAgent({ id, error: 'MISSING_URL' });
+ return;
+ }
+
+ if (!url.startsWith('https://aisandbox-pa.googleapis.com/')) {
+ sendToAgent({ id, error: 'INVALID_URL' });
+ return;
+ }
+
+ setState('running');
+ const hasCaptcha = !!captchaAction;
+ if (hasCaptcha) metrics.requestCount++;
+
+ const logId = id;
+ const logType = _classifyApiUrl(url);
+ if (_VISIBLE_TYPES.has(logType)) {
+ const payloadSummary = body ? JSON.stringify(body).slice(0, 200) : null;
+ addRequestLog({ id: logId, type: logType, time: new Date().toISOString(), status: 'processing', error: null, outputUrl: null, url, payloadSummary });
+ }
+
+ try {
+ // Step 1: Solve captcha if needed
+ let captchaToken = null;
+ if (captchaAction) {
+ const captchaResult = await solveCaptcha(id, captchaAction);
+ captchaToken = captchaResult?.token || null;
+ if (!captchaToken) {
+ // Cannot proceed without captcha — API will 403
+ const err = captchaResult?.error || 'CAPTCHA_FAILED';
+ console.error(`[Flow Agent] Captcha failed for ${captchaAction}: ${err}`);
+ sendToAgent({ id, status: 403, error: `CAPTCHA_FAILED: ${err}` });
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = `CAPTCHA_FAILED: ${err}`; }
+ chrome.storage.local.set({ metrics });
+ updateRequestLog(logId, { status: 'failed', error: `CAPTCHA_FAILED: ${err}` });
+ setState('idle');
+ return;
+ }
+ }
+
+ // Step 2: Inject captcha token into body
+ let finalBody = body;
+ if (captchaToken && finalBody) {
+ finalBody = JSON.parse(JSON.stringify(finalBody)); // deep clone
+ if (finalBody.clientContext?.recaptchaContext) {
+ finalBody.clientContext.recaptchaContext.token = captchaToken;
+ }
+ if (finalBody.requests && Array.isArray(finalBody.requests)) {
+ for (const req of finalBody.requests) {
+ if (req.clientContext?.recaptchaContext) {
+ req.clientContext.recaptchaContext.token = captchaToken;
+ }
+ }
+ }
+ }
+
+ // Step 3: Use flowKey for auth
+ const activeFlowKey = flowKey;
+ if (!activeFlowKey) {
+ sendToAgent({ id, status: 503, error: 'NO_FLOW_KEY' });
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = 'NO_FLOW_KEY'; }
+ chrome.storage.local.set({ metrics });
+ updateRequestLog(logId, { status: 'failed', error: 'NO_FLOW_KEY' });
+ setState('idle');
+ return;
+ }
+
+ const fetchHeaders = { ...(headers || {}) };
+ fetchHeaders['authorization'] = `Bearer ${activeFlowKey}`;
+
+ // Step 4: Make the API call from browser context
+ const response = await fetch(url, {
+ method: method || 'POST',
+ headers: fetchHeaders,
+ credentials: 'include',
+ body: method === 'GET' ? undefined : JSON.stringify(finalBody),
+ });
+
+ let responseData;
+ const responseText = await response.text();
+ try {
+ responseData = JSON.parse(responseText);
+ } catch {
+ responseData = responseText;
+ }
+
+ sendToAgent({
+ id,
+ status: response.status,
+ data: responseData,
+ });
+
+ const responseSummary = responseText ? responseText.slice(0, 300) : null;
+ if (response.ok) {
+ if (hasCaptcha) { metrics.successCount++; metrics.lastError = null; }
+ updateRequestLog(logId, { status: 'success', httpStatus: response.status, responseSummary });
+ } else {
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = `API_${response.status}`; }
+ updateRequestLog(logId, { status: 'failed', error: `API_${response.status}`, httpStatus: response.status, responseSummary });
+ }
+ } catch (e) {
+ sendToAgent({
+ id,
+ status: 500,
+ error: e.message || 'API_REQUEST_FAILED',
+ });
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = e.message; }
+ updateRequestLog(logId, { status: 'failed', error: e.message || 'API_REQUEST_FAILED' });
+ }
+
+ chrome.storage.local.set({ metrics });
+ setState('idle');
+}
+
+// ─── State & Popup ──────────────────────────────────────────
+
+function setState(newState) {
+ state = newState;
+ const badges = { idle: '●', running: '▶', off: '○' };
+ const colors = { idle: '#22c55e', running: '#f59e0b', off: '#6b7280' };
+ chrome.action.setBadgeText({ text: badges[state] || '' });
+ chrome.action.setBadgeBackgroundColor({ color: colors[state] || '#000' });
+ broadcastStatus();
+}
+
+function broadcastStatus() {
+ chrome.runtime.sendMessage({ type: 'STATUS_PUSH' }).catch(() => {});
+}
+
+chrome.runtime.onMessage.addListener((msg, _, reply) => {
+ if (msg.type === 'STATUS') {
+ reply({
+ connected: ws?.readyState === WebSocket.OPEN,
+ agentConnected: ws?.readyState === WebSocket.OPEN,
+ flowKeyPresent: !!flowKey,
+ manualDisconnect,
+ tokenAge: metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
+ metrics: {
+ requestCount: metrics.requestCount,
+ successCount: metrics.successCount,
+ failedCount: metrics.failedCount,
+ lastError: metrics.lastError,
+ },
+ state,
+ });
+ }
+
+ if (msg.type === 'DISCONNECT') {
+ manualDisconnect = true;
+ if (ws) ws.close();
+ reply({ ok: true });
+ return true;
+ }
+
+ if (msg.type === 'RECONNECT') {
+ manualDisconnect = false;
+ connectToAgent();
+ reply({ ok: true });
+ return true;
+ }
+
+ if (msg.type === 'REQUEST_LOG') {
+ reply({ log: requestLog });
+ return true;
+ }
+
+ if (msg.type === 'OPEN_FLOW_TAB') {
+ chrome.tabs.query({
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
+ }).then((tabs) => {
+ if (tabs.length) {
+ chrome.tabs.update(tabs[0].id, { active: true });
+ reply({ ok: true, tabId: tabs[0].id });
+ } else {
+ chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow' })
+ .then((tab) => reply({ ok: true, tabId: tab.id }))
+ .catch((e) => reply({ error: e.message }));
+ }
+ }).catch((e) => reply({ error: e.message }));
+ return true;
+ }
+
+ if (msg.type === 'REFRESH_TOKEN') {
+ captureTokenFromFlowTab()
+ .then(() => reply({ ok: true }))
+ .catch((e) => reply({ error: e.message }));
+ return true;
+ }
+
+ if (msg.type === 'TEST_CAPTCHA') {
+ solveCaptcha(`test-${Date.now()}`, msg.pageAction || 'IMAGE_GENERATION')
+ .then((r) => reply(r))
+ .catch((e) => reply({ error: e.message }));
+ return true;
+ }
+
+ if (msg.type === 'TRPC_MEDIA_URLS') {
+ handleTrpcMediaUrls(msg.trpcUrl, msg.body);
+ reply({ ok: true });
+ return true;
+ }
+
+ if (msg.type === 'SNIFFED_AISANDBOX_REQUEST') {
+ console.log('[Flow Agent] SNIFFED aisandbox request:', msg.url);
+ fetch('http://127.0.0.1:8100/api/ext/callback', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'sniffed_video_request',
+ url: msg.url,
+ method: msg.method,
+ payload: msg.payload,
+ timestamp: msg.timestamp,
+ }),
+ }).catch((e) => console.error('[Flow Agent] Failed to forward sniffed request:', e));
+ reply({ ok: true });
+ return true;
+ }
+
+ return true;
+});
+
+// ─── TRPC Media URL Extractor ──────────────────────────────
+
+function handleTrpcMediaUrls(trpcUrl, bodyText) {
+ try {
+ // Extract all fresh GCS signed URLs
+ const urlRegex = /https:\/\/storage\.googleapis\.com\/ai-sandbox-videofx\/(?:image|video)\/[0-9a-f-]{36}\?[^"'\s]+/g;
+ const matches = bodyText.match(urlRegex) || [];
+ if (!matches.length) return;
+
+ // Deduplicate and parse
+ const urlMap = {};
+ for (const rawUrl of matches) {
+ // Unescape JSON-escaped URLs
+ const url = rawUrl.replace(/\\u0026/g, '&').replace(/\\/g, '');
+ const mediaMatch = url.match(/\/(image|video)\/([0-9a-f-]{36})\?/);
+ if (mediaMatch) {
+ const [, mediaType, mediaId] = mediaMatch;
+ // Keep last occurrence (freshest)
+ urlMap[mediaId] = { mediaType, url, mediaId };
+ }
+ }
+
+ const entries = Object.values(urlMap);
+ if (!entries.length) return;
+
+ console.log(`[Flow Agent] Captured ${entries.length} fresh media URLs from TRPC`);
+ // URL refresh is silent — don't show in request log
+
+ // Forward to agent for DB update
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'media_urls_refresh',
+ urls: entries,
+ }));
+ }
+ } catch (e) {
+ console.error('[Flow Agent] Failed to extract TRPC media URLs:', e);
+ }
+}
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+// ─── Human-like Telemetry ──────────────────────────────────
+// Periodically send tracking events to Google's analytics endpoints
+// to mimic normal browser behavior.
+
+const _UA = navigator.userAgent;
+let _telemetrySessionId = `;${Date.now()}`;
+
+function _rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
+
+function _buildBatchLogPayload() {
+ const events = [];
+ const types = ['FLOW_IMAGE_LATENCY', 'FLOW_VIDEO_LATENCY'];
+ const count = _rand(1, 3);
+ for (let i = 0; i < count; i++) {
+ events.push({
+ event: types[_rand(0, types.length - 1)],
+ eventProperties: [
+ { key: 'CURRENT_TIME_MS', doubleValue: Date.now() },
+ { key: 'DURATION_MS', doubleValue: _rand(150, 800) },
+ { key: 'USER_AGENT', stringValue: _UA },
+ { key: 'IS_DESKTOP', booleanValue: true },
+ ],
+ eventMetadata: { sessionId: _telemetrySessionId },
+ eventTime: new Date().toISOString(),
+ });
+ }
+ return { appEvents: events };
+}
+
+function _buildFrontendEventsPayload() {
+ const eventTypes = [
+ 'FLOW_IMAGE_LATENCY', 'FLOW_VIDEO_LATENCY', 'GRID_SCROLL_DEPTH',
+ 'FLOW_PROJECT_OPEN', 'FLOW_SCENE_VIEW',
+ ];
+ const count = _rand(1, 4);
+ const events = [];
+ for (let i = 0; i < count; i++) {
+ const et = eventTypes[_rand(0, eventTypes.length - 1)];
+ const params = {
+ USER_AGENT: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: _UA },
+ IS_DESKTOP: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'true' },
+ };
+ if (et.includes('LATENCY')) {
+ params.CURRENT_TIME_MS = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: String(Date.now()) };
+ params.DURATION_MS = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: String(_rand(100, 600)) };
+ }
+ if (et === 'GRID_SCROLL_DEPTH') {
+ params.MEDIA_GENERATION_PAYGATE_TIER = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'PAYGATE_TIER_TWO' };
+ }
+ events.push({
+ eventType: et,
+ metadata: {
+ sessionId: _telemetrySessionId,
+ createTime: new Date().toISOString(),
+ additionalParams: params,
+ },
+ });
+ }
+ return { events };
+}
+
+async function sendTelemetry() {
+ if (!flowKey || state === 'off') return;
+
+ const headers = {
+ 'Content-Type': 'text/plain;charset=UTF-8',
+ 'authorization': `Bearer ${flowKey}`,
+ };
+
+ // Telemetry is silent — don't show in request log
+ try {
+ if (Math.random() < 0.5) {
+ await fetch(`https://aisandbox-pa.googleapis.com/v1:batchLog`, {
+ method: 'POST', headers, credentials: 'include',
+ body: JSON.stringify(_buildBatchLogPayload()),
+ });
+ } else {
+ await fetch(`https://aisandbox-pa.googleapis.com/v1/flow:batchLogFrontendEvents`, {
+ method: 'POST', headers, credentials: 'include',
+ body: JSON.stringify(_buildFrontendEventsPayload()),
+ });
+ }
+ } catch {}
+}
+
+// Send telemetry at random intervals (45-120s) to look organic
+function scheduleTelemetry() {
+ const delay = _rand(45, 120) * 1000;
+ setTimeout(async () => {
+ await sendTelemetry();
+ scheduleTelemetry(); // reschedule with new random interval
+ }, delay);
+}
+
+// Refresh session ID every ~30min like a real user
+setInterval(() => { _telemetrySessionId = `;${Date.now()}`; }, _rand(25, 35) * 60 * 1000);
+
+scheduleTelemetry();
+
+console.log('[Flow Agent] Extension loaded');
diff --git a/flow-agent/extension/content.js b/flow-agent/extension/content.js
new file mode 100644
index 0000000000000000000000000000000000000000..8a7e04c63f5c23d7ecdbe6c7337f66d93fb1dc4d
--- /dev/null
+++ b/flow-agent/extension/content.js
@@ -0,0 +1,91 @@
+/**
+ * Content script — bridge between background.js and injected.js
+ * Injects injected.js into MAIN world to access window.grecaptcha
+ */
+(function () {
+ const s = document.createElement('script');
+ s.src = chrome.runtime.getURL('injected.js');
+ s.onload = () => s.remove();
+ (document.head || document.documentElement).appendChild(s);
+})();
+
+chrome.runtime.onMessage.addListener((msg, _, reply) => {
+ if (msg.type !== 'GET_CAPTCHA') return;
+
+ const { requestId, pageAction } = msg;
+
+ const handler = (e) => {
+ if (e.detail?.requestId === requestId) {
+ window.removeEventListener('CAPTCHA_RESULT', handler);
+ clearTimeout(timer);
+ reply({ token: e.detail.token, error: e.detail.error });
+ }
+ };
+
+ const timer = setTimeout(() => {
+ window.removeEventListener('CAPTCHA_RESULT', handler);
+ reply({ error: 'CONTENT_TIMEOUT' });
+ }, 25000);
+
+ window.addEventListener('CAPTCHA_RESULT', handler);
+
+ window.dispatchEvent(new CustomEvent('GET_CAPTCHA', {
+ detail: { requestId, pageAction },
+ }));
+
+ return true; // keep channel open for async reply
+});
+
+// ─── TRPC Media URL Monitor ─────────────────────────────────
+// Forward intercepted TRPC responses with media URLs to background.js
+window.addEventListener('TRPC_MEDIA_URLS', (e) => {
+ const { url, body } = e.detail || {};
+ if (!body) return;
+ chrome.runtime.sendMessage({
+ type: 'TRPC_MEDIA_URLS',
+ trpcUrl: url,
+ body,
+ }).catch(() => {});
+});
+
+// ─── Aisandbox Request Sniffer (via postMessage from MAIN world) ──
+window.addEventListener('message', (e) => {
+ if (e.data?.type !== '__FLOWKIT_SNIFF__') return;
+ const { url, body, method } = e.data;
+ if (!url) return;
+ chrome.runtime.sendMessage({
+ type: 'SNIFFED_AISANDBOX_REQUEST',
+ url,
+ method,
+ payload: body,
+ timestamp: Date.now(),
+ }).catch(() => {});
+});
+
+// ─── Video Upload Relay ─────────────────────────────────────
+chrome.runtime.onMessage.addListener((msg, _, reply) => {
+ if (msg.type !== 'UPLOAD_VIDEO') return;
+
+ const { requestId, videoBase64, projectId } = msg;
+
+ const handler = (e) => {
+ if (e.detail?.requestId === requestId) {
+ window.removeEventListener('UPLOAD_VIDEO_RESULT', handler);
+ clearTimeout(timer);
+ reply(e.detail);
+ }
+ };
+
+ const timer = setTimeout(() => {
+ window.removeEventListener('UPLOAD_VIDEO_RESULT', handler);
+ reply({ error: 'UPLOAD_TIMEOUT' });
+ }, 120000); // 2 min timeout for large uploads
+
+ window.addEventListener('UPLOAD_VIDEO_RESULT', handler);
+
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO', {
+ detail: { requestId, videoBase64, projectId },
+ }));
+
+ return true; // keep channel open for async reply
+});
diff --git a/flow-agent/extension/icon128.png b/flow-agent/extension/icon128.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0add6229bb59214edadc80374582e57f935161e
Binary files /dev/null and b/flow-agent/extension/icon128.png differ
diff --git a/flow-agent/extension/icon16.png b/flow-agent/extension/icon16.png
new file mode 100644
index 0000000000000000000000000000000000000000..51bd12095ce39f2404f773e00197fd3cc6d938fe
Binary files /dev/null and b/flow-agent/extension/icon16.png differ
diff --git a/flow-agent/extension/icon48.png b/flow-agent/extension/icon48.png
new file mode 100644
index 0000000000000000000000000000000000000000..ed77e3d572389959e1d8265666f17cad1e5931b7
Binary files /dev/null and b/flow-agent/extension/icon48.png differ
diff --git a/flow-agent/extension/injected.js b/flow-agent/extension/injected.js
new file mode 100644
index 0000000000000000000000000000000000000000..0841ea9e5b36f9585268f2e51c03003dccca05f5
--- /dev/null
+++ b/flow-agent/extension/injected.js
@@ -0,0 +1,163 @@
+/**
+ * Injected into MAIN world on labs.google — has access to window.grecaptcha
+ * Also intercepts TRPC fetch responses to capture fresh signed media URLs.
+ */
+const SITE_KEY = '6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV';
+
+// ─── XHR Interceptor (for file uploads) ─────────────────────
+const _xhrOpen = XMLHttpRequest.prototype.open;
+const _xhrSend = XMLHttpRequest.prototype.send;
+XMLHttpRequest.prototype.open = function (method, url, ...rest) {
+ this.__sniffUrl = url;
+ this.__sniffMethod = method;
+ return _xhrOpen.call(this, method, url, ...rest);
+};
+XMLHttpRequest.prototype.send = function (body) {
+ try {
+ const url = this.__sniffUrl || '';
+ if (url.includes('googleapis.com') || url.includes('labs.google') || url.includes('storage.google')) {
+ window.postMessage({
+ type: '__FLOWKIT_SNIFF__',
+ url,
+ body: typeof body === 'string' ? body : `(binary ${body?.size || body?.byteLength || '?'} bytes)`,
+ method: this.__sniffMethod || 'POST',
+ }, '*');
+ }
+ } catch {}
+ return _xhrSend.call(this, body);
+};
+
+// ─── TRPC Response Monitor ─────────────────────────────────
+// Monkey-patch fetch to intercept TRPC responses containing media URLs.
+// Fresh signed GCS URLs are extracted and forwarded to the agent.
+
+const _originalFetch = window.fetch;
+window.fetch = async function (...args) {
+ try {
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
+
+ // ─── SNIFF ALL outgoing requests (catch upload) ─────────
+ {
+ let bodyText = '';
+ if (args[1]?.body) {
+ const b = args[1].body;
+ if (typeof b === 'string') bodyText = b.length > 5000 ? b.slice(0, 200) + `...(${b.length} chars)` : b;
+ else if (b instanceof FormData) bodyText = `(FormData: ${[...b.keys()].join(', ')})`;
+ else if (b instanceof Blob) bodyText = `(Blob ${b.size} bytes, type=${b.type})`;
+ else if (b instanceof ArrayBuffer) bodyText = `(ArrayBuffer ${b.byteLength} bytes)`;
+ else if (b instanceof ReadableStream) bodyText = '(ReadableStream)';
+ else bodyText = JSON.stringify(b)?.slice(0, 2000) || '(unknown)';
+ }
+ window.postMessage({
+ type: '__FLOWKIT_SNIFF__',
+ url, body: bodyText, method: args[1]?.method || 'GET',
+ }, '*');
+ }
+ } catch {}
+
+ const response = await _originalFetch.apply(this, args);
+ try {
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
+ // Only intercept TRPC calls on labs.google that return project/flow data
+ if (url.includes('/fx/api/trpc/') && response.ok) {
+ const clone = response.clone();
+ clone.text().then(text => {
+ if (text.includes('storage.googleapis.com/ai-sandbox-videofx/')) {
+ window.dispatchEvent(new CustomEvent('TRPC_MEDIA_URLS', {
+ detail: { url, body: text },
+ }));
+ }
+ }).catch(() => {});
+ }
+ } catch {}
+ return response;
+};
+
+
+window.addEventListener('GET_CAPTCHA', async ({ detail }) => {
+ const { requestId, pageAction } = detail;
+ try {
+ await waitForGrecaptcha();
+ const token = await window.grecaptcha.enterprise.execute(SITE_KEY, {
+ action: pageAction,
+ });
+ window.dispatchEvent(new CustomEvent('CAPTCHA_RESULT', {
+ detail: { requestId, token },
+ }));
+ } catch (e) {
+ window.dispatchEvent(new CustomEvent('CAPTCHA_RESULT', {
+ detail: { requestId, error: e.message },
+ }));
+ }
+});
+
+function waitForGrecaptcha(timeout = 10000) {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const check = () => {
+ if (window.grecaptcha?.enterprise?.execute) return resolve();
+ if (Date.now() - start > timeout) return reject(new Error('grecaptcha not available'));
+ setTimeout(check, 200);
+ };
+ check();
+ });
+}
+
+// ─── Video Upload Handler ───────────────────────────────────
+window.addEventListener('UPLOAD_VIDEO', async ({ detail }) => {
+ const { requestId, videoBase64, projectId } = detail;
+ try {
+ // Convert base64 to Blob
+ const byteChars = atob(videoBase64);
+ const byteArray = new Uint8Array(byteChars.length);
+ for (let i = 0; i < byteChars.length; i++) {
+ byteArray[i] = byteChars.charCodeAt(i);
+ }
+ const blob = new Blob([byteArray], { type: 'video/mp4' });
+
+ // Step 1: POST start — get session URL
+ const startResp = await _originalFetch('/fx/api/upload-video?action=start', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'X-Upload-Project-Id': projectId || '',
+ 'X-Upload-Content-Type': 'video/mp4',
+ 'X-Upload-Content-Length': blob.size.toString(),
+ },
+ });
+ const sessionUrl = startResp.headers.get('X-Upload-Session-Url') || '';
+ const startData = await startResp.json().catch(() => ({}));
+ // sessionUrl may be in header OR in response body
+ const finalSessionUrl = sessionUrl || startData.sessionUrl || '';
+ startData._sessionUrl = finalSessionUrl;
+ startData._status = startResp.status;
+
+ if (!finalSessionUrl) {
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', {
+ detail: { requestId, error: 'NO_SESSION_URL', startData },
+ }));
+ return;
+ }
+
+ // Step 2: PUT directly to GCS session URL with resumable upload headers
+ const uploadResp = await _originalFetch(finalSessionUrl, {
+ method: 'PUT',
+ body: blob,
+ headers: {
+ 'Content-Type': 'video/mp4',
+ 'X-Goog-Upload-Command': 'upload, finalize',
+ 'X-Goog-Upload-Offset': '0',
+ },
+ });
+ const uploadData = await uploadResp.json().catch(() => ({}));
+ uploadData._status = uploadResp.status;
+
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', {
+ detail: { requestId, startData, uploadData, status: uploadResp.status },
+ }));
+ } catch (e) {
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', {
+ detail: { requestId, error: e.message },
+ }));
+ }
+});
diff --git a/flow-agent/extension/manifest.json b/flow-agent/extension/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..5a9d1cf8dedb0fa92b85dda0964ad01748a62653
--- /dev/null
+++ b/flow-agent/extension/manifest.json
@@ -0,0 +1,59 @@
+{
+ "manifest_version": 3,
+ "name": "Flow Agent",
+ "version": "1.0.0",
+ "description": "Automate Google Flow — T2V, V2V, I2V video + T2I, I2I unlimited image generation from terminal",
+ "icons": {
+ "16": "icon16.png",
+ "48": "icon48.png",
+ "128": "icon128.png"
+ },
+ "permissions": ["storage", "alarms", "tabs", "webRequest", "scripting", "declarativeNetRequest", "sidePanel"],
+ "host_permissions": [
+ "https://labs.google/*",
+ "https://aisandbox-pa.googleapis.com/*",
+ "https://aisandbox-pa.sandbox.googleapis.com/*",
+ "https://storage.googleapis.com/*",
+ "http://127.0.0.1:8100/*"
+ ],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "content_scripts": [
+ {
+ "matches": [
+ "https://labs.google/fx/tools/flow*",
+ "https://labs.google/fx/*/tools/flow*"
+ ],
+ "js": ["content.js"],
+ "run_at": "document_start"
+ }
+ ],
+ "web_accessible_resources": [
+ {
+ "resources": ["injected.js"],
+ "matches": ["https://labs.google/*"]
+ }
+ ],
+ "declarative_net_request": {
+ "rule_resources": [
+ {
+ "id": "referer_rules",
+ "enabled": true,
+ "path": "rules.json"
+ }
+ ]
+ },
+ "side_panel": {
+ "default_path": "side_panel.html"
+ },
+ "action": {
+ "default_popup": "popup.html",
+ "default_title": "Flow Agent",
+ "default_icon": {
+ "16": "icon16.png",
+ "48": "icon48.png",
+ "128": "icon128.png"
+ }
+ }
+}
diff --git a/flow-agent/extension/popup.html b/flow-agent/extension/popup.html
new file mode 100644
index 0000000000000000000000000000000000000000..d5ef775f5a695030ff59b1a66c119244b91a2713
--- /dev/null
+++ b/flow-agent/extension/popup.html
@@ -0,0 +1,337 @@
+
+
+
+
+
+ Flow Agent
+
+
+
+
+
+
+
+
`;
+ }).join('');
+
+ overlay.classList.add('open');
+}
+
+document.getElementById('detail-close').addEventListener('click', () => {
+ document.getElementById('detail-overlay').classList.remove('open');
+});
+
+document.getElementById('detail-overlay').addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) {
+ e.currentTarget.classList.remove('open');
+ }
+});
+
+// ── Initial data fetch ───────────────────────────────────────
+
+function fetchStatus() {
+ chrome.runtime.sendMessage({ type: 'STATUS' }, (data) => {
+ if (chrome.runtime.lastError) return;
+ updateStatus(data);
+ });
+}
+
+function fetchLog() {
+ chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => {
+ if (chrome.runtime.lastError) return;
+ if (data && data.log) updateRequestLog(data.log);
+ });
+}
+
+// ── Message listener (push updates) ─────────────────────────
+
+chrome.runtime.onMessage.addListener((msg) => {
+ if (msg.type === 'STATUS_PUSH') {
+ fetchStatus();
+ }
+ if (msg.type === 'REQUEST_LOG_UPDATE') {
+ if (msg.log) updateRequestLog(msg.log);
+ }
+});
+
+// ── Toggle (connect / disconnect) ───────────────────────────
+
+document.getElementById('main-toggle').addEventListener('change', (e) => {
+ const msgType = e.target.checked ? 'RECONNECT' : 'DISCONNECT';
+ chrome.runtime.sendMessage({ type: msgType }, () => {
+ if (chrome.runtime.lastError) return;
+ setTimeout(fetchStatus, 400);
+ });
+});
+
+// ── Action buttons ───────────────────────────────────────────
+
+document.getElementById('btn-flow').addEventListener('click', () => {
+ chrome.runtime.sendMessage({ type: 'OPEN_FLOW_TAB' }, () => {
+ if (chrome.runtime.lastError) return;
+ });
+});
+
+document.getElementById('btn-token').addEventListener('click', () => {
+ const btn = document.getElementById('btn-token');
+ btn.textContent = 'Opening...';
+ btn.disabled = true;
+ chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' }, () => {
+ if (chrome.runtime.lastError) { /* ignore */ }
+ btn.textContent = 'Refresh Token';
+ btn.disabled = false;
+ });
+});
+
+// ── Init ─────────────────────────────────────────────────────
+
+document.addEventListener('DOMContentLoaded', () => {
+ fetchStatus();
+ fetchLog();
+});
diff --git a/flow-agent/media-id.js b/flow-agent/media-id.js
new file mode 100644
index 0000000000000000000000000000000000000000..8df590cab1906dace4810ca095636f87af2d84ca
--- /dev/null
+++ b/flow-agent/media-id.js
@@ -0,0 +1,12 @@
+cat_beach.png : cc312ed8-e13f-4b89-a25d-4d45786daf15
+dragon_end.png : 9f7ee671-62cf-4380-8896-38a1cc0ce943
+dragon_start.png : 76690529-192b-42ee-b6d5-ebc003ec7467
+end_frame.png : dfa0816c-dfc5-41bd-bbbf-0ef865a377de
+start_frame.png : 41f43cdb-293f-4e41-8bb2-664fd269619b
+upload_08eb0364414643f9bede2b4e139375d9_test_t2i.png : a821e119-ebdb-419d-8991-f80ecfa63637
+upload_125349fc756b4e6f8ead3b2a9d6aa036_test_i2v.mp4 : 84e699af-72ba-45db-9a0e-ea60cda3420b
+upload_55a0bcc69fa74bf4a85790a23ba0e92a_test_t2i.png : e6dcaab9-266f-4dd1-bf04-63bf8cd5a27b
+upload_6fb7eab112a04007b7468ce3b4a7c5d9_test_t2i.png : 0e61aa50-fc25-4c58-95ff-ea2587e66265
+upload_c0e77a6605844f5d93182e9fc6aaaa5e_test_i2v.mp4 : 9f598bab-5f73-403d-b679-7cd223f42523
+upload_cd9aed3690464e06953eb74c5b254619_test_t2i.png : a2ef1c23-c2bb-42a9-8cd2-03264a754623
+upload_ce5bb751ddeb461c9392c617af058186_test_i2v.mp4 : 8eb1f60d-0f6a-44b3-835d-8f268c565818
diff --git a/flow-agent/omni.py b/flow-agent/omni.py
new file mode 100644
index 0000000000000000000000000000000000000000..6bf49301424acb72e9b13d48272a3abe3e9cebb4
--- /dev/null
+++ b/flow-agent/omni.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""Omni Flash — Backward-compatible wrapper.
+
+This file preserves the old `from omni import ...` interface.
+All logic lives in the omniflash/ package now.
+
+Usage:
+ python omni.py "A dragon breathing fire"
+ python omni.py "Eagle soaring" --aspect landscape -o eagle.mp4
+"""
+
+# Re-export everything from the package
+from omniflash import (
+ ExtensionBridge,
+ generate_video,
+ edit_video,
+ upload_image,
+ generate_video_i2v,
+ poll_status,
+ download_video,
+ build_client_context,
+ upload_video,
+ media_store,
+ ASPECTS,
+ DEFAULT_PROJECT,
+ ENDPOINTS,
+ CLIENT_CTX,
+ API_KEY,
+ API_BASE,
+)
+
+# CLI entry point
+if __name__ == "__main__":
+ from cli.generate import main
+ main()
diff --git a/flow-agent/omniflash/__init__.py b/flow-agent/omniflash/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7dd8410319b080047603b508ec5038e152ec8678
--- /dev/null
+++ b/flow-agent/omniflash/__init__.py
@@ -0,0 +1,61 @@
+"""Omni Flash — AI Video Generation & Editing toolkit.
+
+Usage:
+ from omniflash import ExtensionBridge, generate_video, poll_status, download_video
+ from omniflash import edit_video, upload_image, generate_video_i2v
+ from omniflash.config import ASPECTS, DEFAULT_PROJECT
+ from omniflash.upload import upload_video
+ from omniflash import media_store
+"""
+
+# Auto-install dependencies
+import os
+import sys
+for _pkg in ["websockets"]:
+ try:
+ __import__(_pkg)
+ except ImportError:
+ os.system(f"{sys.executable} -m pip install {_pkg} -q")
+
+import logging
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
+
+# ─── Public API ──────────────────────────────────────────────
+
+from .bridge import ExtensionBridge
+from .config import ASPECTS, DEFAULT_PROJECT, ENDPOINTS, CLIENT_CTX, API_KEY, API_BASE
+from .generators import (
+ generate_video,
+ edit_video,
+ upload_image,
+ generate_video_i2v,
+ poll_status,
+ download_video,
+ build_client_context,
+)
+from .upload import upload_video
+from . import media_store
+
+__all__ = [
+ # Bridge
+ "ExtensionBridge",
+ # Generators
+ "generate_video",
+ "edit_video",
+ "upload_image",
+ "generate_video_i2v",
+ "poll_status",
+ "download_video",
+ "build_client_context",
+ # Upload
+ "upload_video",
+ # Config
+ "ASPECTS",
+ "DEFAULT_PROJECT",
+ "ENDPOINTS",
+ "CLIENT_CTX",
+ "API_KEY",
+ "API_BASE",
+ # Media store
+ "media_store",
+]
diff --git a/flow-agent/omniflash/bridge.py b/flow-agent/omniflash/bridge.py
new file mode 100644
index 0000000000000000000000000000000000000000..6c2d3fcc7526fab09d5ed6650acab37951c4f6c8
--- /dev/null
+++ b/flow-agent/omniflash/bridge.py
@@ -0,0 +1,277 @@
+"""Omni Flash — ExtensionBridge.
+
+WebSocket + HTTP server that communicates with the Chrome extension.
+Handles auth token capture, API proxying, and request/response routing.
+"""
+
+import asyncio
+import json
+import logging
+import random
+import threading
+import uuid
+from http.server import HTTPServer, BaseHTTPRequestHandler
+
+import websockets
+
+from .config import (
+ WS_PORT, HTTP_PORT, API_BASE, API_KEY,
+ CLIENT_CTX, USER_AGENTS,
+)
+
+log = logging.getLogger("omniflash.bridge")
+
+
+class ExtensionBridge:
+ """WebSocket server that Chrome extension connects to."""
+
+ def __init__(self):
+ self._ws = None
+ self._pending: dict[str, asyncio.Future] = {}
+ self._flow_key = None
+ self._connected = asyncio.Event()
+ self._loop = None
+
+ async def start(self):
+ """Start WS server and HTTP callback server."""
+ self._loop = asyncio.get_event_loop()
+ self._start_http_server()
+
+ self._ws_server = await websockets.serve(
+ self._on_connect, "127.0.0.1", WS_PORT
+ )
+ log.info("⚡ WebSocket server on ws://127.0.0.1:%d", WS_PORT)
+ log.info("⚡ HTTP callback on http://127.0.0.1:%d", HTTP_PORT)
+ log.info("⏳ Waiting for Chrome extension to connect...")
+
+ async def wait_for_extension(self, timeout=90, max_retries=3):
+ """Wait until extension connects and sends flow key.
+
+ Phase 1: Wait for WebSocket connection from extension.
+ Phase 2: If no token, auto-open/refresh Flow tab and wait for token.
+ """
+ # Phase 1: Wait for WS connection
+ try:
+ await asyncio.wait_for(self._wait_for_ws(), 30)
+ except asyncio.TimeoutError:
+ log.error("❌ Extension didn't connect in 30s")
+ log.error(" Make sure Flow Agent extension is installed and enabled in Chrome")
+ return False
+
+ # If token already present, we're good
+ if self._flow_key:
+ return True
+
+ # Phase 2: Extension connected but no token — auto-fix
+ log.info("⚠️ Extension connected but no auth token — auto-fixing...")
+
+ for attempt in range(1, max_retries + 1):
+ log.info("🔄 Attempt %d/%d: Opening/refreshing Flow tab...", attempt, max_retries)
+ await self._request_flow_tab()
+
+ # Wait for token to arrive (token_captured message)
+ token_arrived = await self._wait_for_token(20)
+ if token_arrived:
+ log.info("✅ Token captured after auto-fix!")
+ return True
+
+ log.warning("⏳ Token not captured yet...")
+
+ log.error("❌ Could not get auth token after %d retries", max_retries)
+ log.error(" Make sure you're logged into Google at labs.google/fx/tools/flow")
+ return False
+
+ async def _wait_for_ws(self):
+ """Wait until a WebSocket connection is established."""
+ while not self._ws:
+ await asyncio.sleep(0.5)
+
+ async def _wait_for_token(self, timeout):
+ """Wait until a valid token is captured."""
+ self._connected.clear()
+ try:
+ await asyncio.wait_for(self._connected.wait(), timeout)
+ return True
+ except asyncio.TimeoutError:
+ return self._flow_key is not None
+
+ async def _request_flow_tab(self):
+ """Ask extension to open or refresh a Flow tab."""
+ if not self._ws:
+ return
+ try:
+ log.info("📂 Requesting extension to open/refresh Flow tab...")
+ await self._ws.send(json.dumps({"method": "open_flow_tab"}))
+ # Wait for page to fully load before requesting token refresh
+ await asyncio.sleep(8)
+ log.info("🔄 Requesting token refresh from Flow tab...")
+ await self._ws.send(json.dumps({"method": "refresh_flow_tab"}))
+ except Exception as e:
+ log.debug("Failed to request flow tab: %s", e)
+
+ async def health_check(self):
+ """Quick check if extension is ready with valid token."""
+ if not self._ws or not self._flow_key:
+ return False
+ try:
+ req_id = str(uuid.uuid4())
+ future = self._loop.create_future()
+ self._pending[req_id] = future
+ await self._ws.send(json.dumps({
+ "id": req_id,
+ "method": "get_status",
+ }))
+ result = await asyncio.wait_for(future, timeout=5)
+ self._pending.pop(req_id, None)
+ return result.get("result", {}).get("flowKeyPresent", False)
+ except Exception:
+ self._pending.pop(req_id, None)
+ return False
+
+ async def _on_connect(self, ws):
+ self._ws = ws
+ log.info("✅ Extension connected!")
+ try:
+ async for raw in ws:
+ data = json.loads(raw)
+ await self._handle_message(data)
+ except websockets.exceptions.ConnectionClosed:
+ log.warning("Extension disconnected")
+ self._ws = None
+ self._connected.clear()
+
+ async def _handle_message(self, data):
+ msg_type = data.get("type")
+
+ if msg_type == "token_captured":
+ self._flow_key = data.get("flowKey")
+ log.info("🔑 Auth token captured")
+ self._connected.set()
+
+ elif msg_type == "extension_ready":
+ log.info("Extension ready (flowKey=%s)", "yes" if data.get("flowKeyPresent") else "no")
+ if data.get("flowKeyPresent") and self._flow_key:
+ self._connected.set()
+
+ elif msg_type in ("pong", "ping"):
+ if msg_type == "ping" and self._ws:
+ await self._ws.send(json.dumps({"type": "pong"}))
+
+ else:
+ req_id = data.get("id")
+ if req_id and req_id in self._pending:
+ if not self._pending[req_id].done():
+ self._pending[req_id].set_result(data)
+
+ def handle_http_callback(self, data):
+ """Called from HTTP thread when extension sends callback."""
+ req_id = data.get("id")
+ if req_id and req_id in self._pending:
+ self._loop.call_soon_threadsafe(
+ self._resolve_pending, req_id, data
+ )
+ return True
+ if data.get("type") == "token_captured":
+ self._flow_key = data.get("flowKey")
+ self._loop.call_soon_threadsafe(self._connected.set)
+ return True
+ return False
+
+ def _resolve_pending(self, req_id, data):
+ if req_id in self._pending and not self._pending[req_id].done():
+ self._pending[req_id].set_result(data)
+
+ async def api_request(self, url_path, body, captcha_action="VIDEO_GENERATION", method="POST"):
+ """Send API request through Chrome extension."""
+ if not self._ws:
+ return {"error": "Extension not connected"}
+
+ req_id = str(uuid.uuid4())
+ future = self._loop.create_future()
+ self._pending[req_id] = future
+
+ url = f"{API_BASE}{url_path}?key={API_KEY}"
+ ua = random.choice(USER_AGENTS)
+ platform = '"macOS"' if "Macintosh" in ua else '"Windows"'
+
+ msg = {
+ "id": req_id,
+ "method": "api_request",
+ "params": {
+ "url": url,
+ "method": method,
+ "headers": {
+ "accept": "*/*",
+ "content-type": "text/plain;charset=UTF-8",
+ "origin": CLIENT_CTX["origin"],
+ "referer": CLIENT_CTX["origin"] + "/",
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": platform,
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "cross-site",
+ "user-agent": ua,
+ },
+ "body": body,
+ "captchaAction": captcha_action,
+ },
+ }
+ await self._ws.send(json.dumps(msg))
+
+ try:
+ result = await asyncio.wait_for(future, timeout=90)
+ return result
+ except asyncio.TimeoutError:
+ return {"error": "TIMEOUT"}
+ finally:
+ self._pending.pop(req_id, None)
+
+ def _start_http_server(self):
+ """Start HTTP server for extension callbacks (runs in thread)."""
+ bridge = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_POST(self):
+ if self.path == "/api/ext/callback":
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length)) if length else {}
+ bridge.handle_http_callback(body)
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(b'{"ok":true}')
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def do_GET(self):
+ if self.path == "/health":
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({
+ "status": "ok",
+ "extension_connected": bridge._ws is not None,
+ }).encode())
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.end_headers()
+
+ def log_message(self, *args):
+ pass
+
+ server = HTTPServer(("127.0.0.1", HTTP_PORT), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ async def close(self):
+ self._ws_server.close()
+ await self._ws_server.wait_closed()
diff --git a/flow-agent/omniflash/config.py b/flow-agent/omniflash/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..50a8adb39ef3825c3a70e9e88827329128a67ab8
--- /dev/null
+++ b/flow-agent/omniflash/config.py
@@ -0,0 +1,84 @@
+"""Flow Agent — Configuration.
+
+All constants hardcoded. No external config files needed.
+"""
+
+import os
+
+# ─── Paths ───────────────────────────────────────────────────
+
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+MEDIA_ID_FILE = os.path.join(ROOT_DIR, "media-id.js")
+
+# ─── Project ─────────────────────────────────────────────────
+
+DEFAULT_PROJECT = "0143adf4-5864-4cb4-abb5-fe4254ad0dc7"
+
+# Image model: NARWHAL (Imagen 4 / Nano Banana 2), GEM_PIX_2, IMAGEN_4
+IMAGE_MODEL = "NARWHAL"
+
+# ─── Hardcoded constants (never change) ──────────────────────
+
+API_KEY = "AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY"
+API_BASE = "https://aisandbox-pa.googleapis.com"
+
+CLIENT_CTX = {
+ "tool": "PINHOLE",
+ "tier": "PAYGATE_TIER_ONE",
+ "origin": "https://labs.google",
+ "recaptcha_app_type": "RECAPTCHA_APPLICATION_TYPE_WEB",
+}
+
+ASPECTS = {
+ "portrait": "VIDEO_ASPECT_RATIO_PORTRAIT",
+ "landscape": "VIDEO_ASPECT_RATIO_LANDSCAPE",
+}
+
+ENDPOINTS = {
+ "generate_t2v": "/v1/video:batchAsyncGenerateVideoText",
+ "generate_i2v": "/v1/video:batchAsyncGenerateVideoStartImage",
+ "generate_fl": "/v1/video:batchAsyncGenerateVideoStartAndEndImage",
+ "generate_r2v": "/v1/video:batchAsyncGenerateVideoReferenceImages",
+ "generate_edit": "/v1/video:batchAsyncGenerateVideoEditVideo",
+ "upload_image": "/v1/flow/uploadImage",
+ "poll_status": "/v1/video:batchCheckAsyncVideoGenerationStatus",
+ "get_media": "/v1/media/{media_id}",
+ "get_credits": "/v1/credits",
+}
+
+MODELS = {
+ "t2v": {
+ 4: "abra_t2v_4s",
+ 6: "abra_t2v_6s",
+ 8: "abra_t2v_8s",
+ 10: "abra_t2v_10s",
+ },
+ "edit": "abra_edit",
+}
+
+DURATIONS = [4, 6, 8, 10]
+DEFAULT_DURATION = 10
+MAX_COUNT = 4
+
+CREDITS_PER_VIDEO = {
+ 4: 5,
+ 6: 10,
+ 8: 10,
+ 10: 15,
+}
+
+# ─── Runtime constants ───────────────────────────────────────
+
+WS_PORT = int(os.environ.get("WS_PORT", "9222"))
+HTTP_PORT = int(os.environ.get("HTTP_PORT", "8100"))
+
+POLL_INTERVAL = 10
+POLL_TIMEOUT = 420
+
+SEGMENT_DURATION = 10
+FPS = 24
+
+USER_AGENTS = [
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
+]
diff --git a/flow-agent/omniflash/generators/__init__.py b/flow-agent/omniflash/generators/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..737999ef1165d24eb771cb3d5101ca0a35aa6312
--- /dev/null
+++ b/flow-agent/omniflash/generators/__init__.py
@@ -0,0 +1,20 @@
+"""Omni Flash — Generator modules."""
+
+from .t2v import generate_video
+from .v2v import edit_video
+from .i2v import upload_image, generate_video_i2v
+from .t2i import generate_image, download_image, IMAGE_ASPECTS
+from .common import poll_status, download_video, build_client_context
+
+__all__ = [
+ "generate_video",
+ "edit_video",
+ "upload_image",
+ "generate_video_i2v",
+ "generate_image",
+ "download_image",
+ "IMAGE_ASPECTS",
+ "poll_status",
+ "download_video",
+ "build_client_context",
+]
diff --git a/flow-agent/omniflash/generators/common.py b/flow-agent/omniflash/generators/common.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2ed1e293b7467763090c878bb39ff636ffbb6e8
--- /dev/null
+++ b/flow-agent/omniflash/generators/common.py
@@ -0,0 +1,98 @@
+"""Omni Flash — Common utilities for all generators.
+
+Shared functions: client context builder, poll_status, download_video.
+"""
+
+import asyncio
+import base64
+import logging
+import os
+import random
+import time
+import uuid
+
+from ..config import (
+ CLIENT_CTX, ENDPOINTS, POLL_INTERVAL, POLL_TIMEOUT,
+)
+
+log = logging.getLogger("omniflash.generators")
+
+
+def build_client_context(project_id: str) -> dict:
+ """Build the clientContext dict used by all API requests."""
+ return {
+ "projectId": project_id,
+ "tool": CLIENT_CTX["tool"],
+ "userPaygateTier": CLIENT_CTX["tier"],
+ "sessionId": f";{int(time.time() * 1000)}",
+ "recaptchaContext": {
+ "applicationType": CLIENT_CTX["recaptcha_app_type"],
+ "token": "",
+ },
+ }
+
+
+def build_generation_context(audio_pref: str = None) -> dict:
+ """Build the mediaGenerationContext dict."""
+ ctx = {"batchId": str(uuid.uuid4())}
+ if audio_pref:
+ ctx["audioFailurePreference"] = audio_pref
+ return ctx
+
+
+async def poll_status(bridge, media_id: str, project_id: str) -> bool:
+ """Poll until video is ready. Returns True on success."""
+ body = {"media": [{"name": media_id, "projectId": project_id}]}
+ start = time.time()
+
+ while time.time() - start < POLL_TIMEOUT:
+ result = await bridge.api_request(ENDPOINTS["poll_status"], body, captcha_action="")
+ data = result.get("data", {})
+ media = data.get("media", [])
+
+ if media:
+ meta = media[0].get("mediaMetadata", {}).get("mediaStatus", {})
+ status = meta.get("mediaGenerationStatus", "")
+
+ if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL":
+ elapsed = int(time.time() - start)
+ log.info("✅ Video ready! (%ds)", elapsed)
+ return True
+ elif "FAILED" in status or "BLOCKED" in status:
+ log.error("❌ Failed: %s", status)
+ return False
+
+ elapsed = int(time.time() - start)
+ log.info("⏳ Waiting... (%ds)", elapsed)
+ await asyncio.sleep(POLL_INTERVAL)
+
+ log.error("❌ Timeout after %ds", POLL_TIMEOUT)
+ return False
+
+
+async def download_video(bridge, media_id: str, output_path: str) -> bool:
+ """Download video via get_media API."""
+ url_path = ENDPOINTS["get_media"].format(media_id=media_id)
+ result = await bridge.api_request(url_path, {}, captcha_action="", method="GET")
+ data = result.get("data", result)
+
+ video_b64 = ""
+ if isinstance(data, dict):
+ v = data.get("video", {})
+ if isinstance(v, dict):
+ video_b64 = v.get("encodedVideo", "")
+ elif isinstance(v, str):
+ video_b64 = v
+
+ if not video_b64:
+ log.error("❌ No video data in response")
+ return False
+
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
+ video_bytes = base64.b64decode(video_b64)
+ with open(output_path, "wb") as f:
+ f.write(video_bytes)
+
+ size_mb = len(video_bytes) / (1024 * 1024)
+ log.info("✅ Saved: %s (%.1f MB)", output_path, size_mb)
+ return True
diff --git a/flow-agent/omniflash/generators/i2v.py b/flow-agent/omniflash/generators/i2v.py
new file mode 100644
index 0000000000000000000000000000000000000000..9fe5eb8f5d3a5dcf5f9660761546f1b7ffe536e9
--- /dev/null
+++ b/flow-agent/omniflash/generators/i2v.py
@@ -0,0 +1,192 @@
+"""Omni Flash — Image to Video (I2V) generator + image upload."""
+
+import base64
+import logging
+import os
+import random
+
+from ..config import CLIENT_CTX, ENDPOINTS
+from .. import media_store
+from .common import build_client_context, build_generation_context
+
+log = logging.getLogger("omniflash.generators.i2v")
+
+
+async def upload_image(bridge, image_path: str, project_id: str = None) -> str | None:
+ """Upload a local image to Flow. Returns media_id.
+
+ Auto-saves filename → media_id to media-id.js.
+ """
+ from ..config import DEFAULT_PROJECT
+ project_id = project_id or DEFAULT_PROJECT
+
+ with open(image_path, "rb") as f:
+ img_data = base64.b64encode(f.read()).decode()
+
+ body = {
+ "clientContext": {"tool": CLIENT_CTX["tool"], "projectId": project_id},
+ "imageBytes": img_data,
+ }
+
+ log.info("📷 Uploading image: %s", os.path.basename(image_path))
+ result = await bridge.api_request(ENDPOINTS["upload_image"], body)
+
+ status = result.get("status", 0)
+ data = result.get("data", {})
+ if status != 200:
+ err = data.get("error", {}).get("message", "Unknown") if isinstance(data, dict) else str(data)
+ log.error("❌ Image upload failed (%s): %s", status, err)
+ return None
+
+ media_id = data.get("mediaId") or data.get("name")
+ if not media_id and isinstance(data.get("media"), dict):
+ media_id = data["media"].get("name")
+ log.info("✅ Image uploaded! media_id=%s", media_id)
+
+ if media_id:
+ media_store.save(os.path.basename(image_path), media_id)
+
+ return media_id
+
+
+async def generate_video_i2v(bridge, prompt: str, aspect: str, project_id: str,
+ image_media_id: str, duration: int = 8) -> list[str] | None:
+ """Generate video from a start image. Returns list of media_ids."""
+ model_key = f"abra_t2v_{duration}s"
+
+ body = {
+ "mediaGenerationContext": build_generation_context(),
+ "clientContext": build_client_context(project_id),
+ "requests": [{
+ "aspectRatio": aspect,
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
+ "videoModelKey": model_key,
+ "seed": random.randint(1, 9999),
+ "metadata": {},
+ "startImage": {"mediaId": image_media_id},
+ }],
+ }
+
+ log.info('🖼️→🎬 I2V: "%s" [%s] image=%s', prompt[:50], model_key, image_media_id[:12])
+ result = await bridge.api_request(ENDPOINTS["generate_i2v"], body)
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ I2V failed (%s): %s", status, err)
+ return None
+
+ data = result.get("data", {})
+ media = data.get("media", [])
+ if not media:
+ log.error("❌ No media in response")
+ return None
+
+ media_ids = [m.get("name") for m in media]
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ I2V submitted! %d video(s), credits=%s", len(media_ids), credits)
+ return media_ids
+
+
+async def generate_video_fl(bridge, prompt: str, aspect: str, project_id: str,
+ start_image_id: str, end_image_id: str,
+ duration: int = 8) -> list[str] | None:
+ """Generate video with First+Last frame control.
+
+ Video transitions smoothly from start_image to end_image.
+ """
+ model_key = f"abra_t2v_{duration}s"
+
+ request = {
+ "aspectRatio": aspect,
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
+ "videoModelKey": model_key,
+ "seed": random.randint(1, 9999),
+ "metadata": {},
+ "startImage": {"mediaId": start_image_id},
+ "endImage": {"mediaId": end_image_id},
+ }
+
+ body = {
+ "mediaGenerationContext": build_generation_context(),
+ "clientContext": build_client_context(project_id),
+ "requests": [request],
+ "useV2ModelConfig": True,
+ }
+
+ log.info('🎬 FL: "%s" start=%s end=%s', prompt[:40], start_image_id[:12], end_image_id[:12])
+ result = await bridge.api_request(ENDPOINTS["generate_fl"], body)
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ FL failed (%s): %s", status, err)
+ return None
+
+ data = result.get("data", {})
+ media = data.get("media", [])
+ if not media:
+ log.error("❌ No media in response")
+ return None
+
+ media_ids = [m.get("name") for m in media]
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ FL submitted! %d video(s), credits=%s", len(media_ids), credits)
+ return media_ids
+
+
+async def generate_video_r2v(bridge, prompt: str, aspect: str, project_id: str,
+ ref_media_ids: list[str],
+ duration: int = 8) -> list[str] | None:
+ """Generate video from reference images (character/style consistency).
+
+ Uses reference images to maintain visual consistency in the generated video.
+ """
+ model_key = f"abra_t2v_{duration}s"
+
+ ref_images = [
+ {"mediaId": mid, "imageUsageType": "IMAGE_USAGE_TYPE_ASSET"}
+ for mid in ref_media_ids
+ ]
+
+ request = {
+ "aspectRatio": aspect,
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
+ "videoModelKey": model_key,
+ "seed": random.randint(1, 9999),
+ "metadata": {},
+ "referenceImages": ref_images,
+ }
+
+ body = {
+ "mediaGenerationContext": build_generation_context(),
+ "clientContext": build_client_context(project_id),
+ "requests": [request],
+ "useV2ModelConfig": True,
+ }
+
+ log.info('🎬 R2V: "%s" refs=%d', prompt[:50], len(ref_media_ids))
+ result = await bridge.api_request(ENDPOINTS["generate_r2v"], body)
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ R2V failed (%s): %s", status, err)
+ return None
+
+ data = result.get("data", {})
+ media = data.get("media", [])
+ if not media:
+ log.error("❌ No media in response")
+ return None
+
+ media_ids = [m.get("name") for m in media]
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ R2V submitted! %d video(s), credits=%s", len(media_ids), credits)
+ return media_ids
diff --git a/flow-agent/omniflash/generators/t2i.py b/flow-agent/omniflash/generators/t2i.py
new file mode 100644
index 0000000000000000000000000000000000000000..54d9181221dd113a8da12b690f0e2c7f8d604b6d
--- /dev/null
+++ b/flow-agent/omniflash/generators/t2i.py
@@ -0,0 +1,153 @@
+"""Flow Agent — Text to Image (T2I) generator.
+
+Ported from virtual-try/go-server/image.go
+"""
+
+import logging
+import os
+import random
+import re
+import time
+
+from ..config import ENDPOINTS, DEFAULT_PROJECT, IMAGE_MODEL
+from .common import build_client_context, build_generation_context
+
+log = logging.getLogger("omniflash.generators.t2i")
+
+# Image aspect ratios (more options than video)
+IMAGE_ASPECTS = {
+ "landscape": "IMAGE_ASPECT_RATIO_LANDSCAPE", # 16:9
+ "4x3": "IMAGE_ASPECT_RATIO_4_3", # 4:3
+ "square": "IMAGE_ASPECT_RATIO_SQUARE", # 1:1
+ "3x4": "IMAGE_ASPECT_RATIO_3_4", # 3:4
+ "portrait": "IMAGE_ASPECT_RATIO_PORTRAIT", # 9:16
+}
+
+UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+
+
+def _parse_image_results(data: dict) -> list[dict]:
+ """Parse all images from batchGenerateImages response.
+
+ Returns list of {"media_id": str, "image_url": str}
+ """
+ results = []
+ media_list = data.get("media", [])
+
+ for item in media_list:
+ r = {"media_id": "", "image_url": ""}
+
+ # media[i].name = mediaId
+ name = item.get("name", "")
+ if UUID_RE.match(name):
+ r["media_id"] = name
+
+ # media[i].image.generatedImage.fifeUrl
+ img = item.get("image", {})
+ gen = img.get("generatedImage", {})
+ url = gen.get("fifeUrl", "") or gen.get("imageUri", "")
+ if url:
+ r["image_url"] = url
+ # Fallback: extract mediaId from URL
+ if not r["media_id"]:
+ match = UUID_RE.search(url)
+ if match:
+ r["media_id"] = match.group()
+
+ results.append(r)
+
+ return results
+
+
+async def generate_image(bridge, prompt: str, aspect: str, project_id: str,
+ count: int = 1, ref_media_ids: list[str] = None) -> list[dict] | None:
+ """Generate images from text prompt.
+
+ Args:
+ bridge: ExtensionBridge instance
+ prompt: Text prompt for image
+ aspect: Aspect ratio key (portrait/landscape/square/4x3/3x4)
+ project_id: Flow project ID
+ count: Number of variations (1-4)
+ ref_media_ids: Optional reference image media IDs
+
+ Returns:
+ List of {"media_id": str, "image_url": str} or None on error
+ """
+ count = max(1, min(4, count))
+ ts = int(time.time() * 1000)
+
+ aspect_ratio = IMAGE_ASPECTS.get(aspect, aspect)
+
+ # Build N items in requests array (1 API call = N images)
+ requests = []
+ for i in range(count):
+ req_item = {
+ "clientContext": build_client_context(project_id),
+ "seed": (ts + i * 1000) % 1000000,
+ "structuredPrompt": {"parts": [{"text": prompt}]},
+ "imageAspectRatio": aspect_ratio,
+ "imageModelName": IMAGE_MODEL,
+ }
+
+ # Add reference images if provided
+ if ref_media_ids:
+ req_item["imageInputs"] = [
+ {"name": mid, "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"}
+ for mid in ref_media_ids
+ ]
+
+ requests.append(req_item)
+
+ body = {
+ "clientContext": build_client_context(project_id),
+ "requests": requests,
+ }
+
+ if ref_media_ids:
+ body["mediaGenerationContext"] = build_generation_context()
+ body["useNewMedia"] = True
+
+ # Endpoint: /v1/projects/{projectId}/flowMedia:batchGenerateImages
+ endpoint = f"/v1/projects/{project_id}/flowMedia:batchGenerateImages"
+
+ log.info('🖼️ Generating: "%s" [%s] x%d', prompt[:50], aspect, count)
+ result = await bridge.api_request(endpoint, body, captcha_action="IMAGE_GENERATION")
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ Failed (%s): %s", status, err)
+ return None
+
+ data = result.get("data", {})
+ results = _parse_image_results(data)
+
+ if not results:
+ log.error("❌ No images in response")
+ return None
+
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ Generated! %d image(s), credits=%s", len(results), credits)
+ for r in results:
+ log.info(" media_id=%s", r["media_id"][:12] if r["media_id"] else "?")
+
+ return results
+
+
+async def download_image(bridge, image_url: str, output_path: str) -> bool:
+ """Download image from fifeUrl via HTTP GET."""
+ import urllib.request
+
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
+
+ try:
+ urllib.request.urlretrieve(image_url, output_path)
+ size_kb = os.path.getsize(output_path) / 1024
+ log.info("✅ Saved: %s (%.0f KB)", output_path, size_kb)
+ return True
+ except Exception as e:
+ log.error("❌ Download failed: %s", e)
+ return False
diff --git a/flow-agent/omniflash/generators/t2v.py b/flow-agent/omniflash/generators/t2v.py
new file mode 100644
index 0000000000000000000000000000000000000000..85dcbd2e73d91b876228c1e574a689a693373f28
--- /dev/null
+++ b/flow-agent/omniflash/generators/t2v.py
@@ -0,0 +1,57 @@
+"""Omni Flash — Text to Video (T2V) generator."""
+
+import logging
+import random
+import uuid
+
+from ..config import ENDPOINTS
+from .common import build_client_context, build_generation_context
+
+log = logging.getLogger("omniflash.generators.t2v")
+
+
+async def generate_video(bridge, prompt: str, aspect: str, project_id: str,
+ duration: int = 10, count: int = 1) -> list[str] | None:
+ """Submit T2V generation request. Returns list of media_ids."""
+ model_key = f"abra_t2v_{duration}s"
+
+ requests = []
+ for _ in range(count):
+ requests.append({
+ "aspectRatio": aspect,
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
+ "videoModelKey": model_key,
+ "seed": random.randint(1, 9999),
+ "metadata": {},
+ })
+
+ body = {
+ "mediaGenerationContext": build_generation_context(),
+ "clientContext": build_client_context(project_id),
+ "requests": requests,
+ "useV2ModelConfig": True,
+ }
+
+ log.info('🎬 Generating: "%s" [%s] %ds x%d', prompt[:50], model_key, duration, count)
+ result = await bridge.api_request(ENDPOINTS["generate_t2v"], body)
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ Failed (%s): %s", status, err)
+ return None
+
+ data = result.get("data", {})
+ media = data.get("media", [])
+ if not media:
+ log.error("❌ No media in response")
+ return None
+
+ media_ids = [m.get("name") for m in media]
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ Submitted! %d video(s), credits=%s", len(media_ids), credits)
+ for mid in media_ids:
+ log.info(" media_id=%s", mid)
+ return media_ids
diff --git a/flow-agent/omniflash/generators/v2v.py b/flow-agent/omniflash/generators/v2v.py
new file mode 100644
index 0000000000000000000000000000000000000000..331d66354eb3f14dc7d2fd33393d268517c99bf7
--- /dev/null
+++ b/flow-agent/omniflash/generators/v2v.py
@@ -0,0 +1,56 @@
+"""Omni Flash — Video to Video (V2V) editor."""
+
+import logging
+import random
+
+from ..config import ENDPOINTS
+from .common import build_client_context, build_generation_context
+
+log = logging.getLogger("omniflash.generators.v2v")
+
+
+async def edit_video(bridge, prompt: str, aspect: str, project_id: str,
+ video_media_id: str, fps: int = 24, duration: int = 10,
+ start_frame: int = 0, end_frame: int = None) -> list[str] | None:
+ """Submit V2V edit request. Returns list of media_ids."""
+ if end_frame is None:
+ end_frame = fps * duration
+
+ body = {
+ "mediaGenerationContext": build_generation_context("BLOCK_SILENCED_VIDEOS"),
+ "clientContext": build_client_context(project_id),
+ "requests": [{
+ "aspectRatio": aspect,
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
+ "videoModelKey": "abra_edit",
+ "seed": random.randint(1, 9999),
+ "metadata": {},
+ "videoInput": {
+ "mediaId": video_media_id,
+ "startFrameIndex": start_frame,
+ "endFrameIndex": end_frame,
+ },
+ }],
+ }
+
+ log.info('✂️ Editing: "%s" [abra_edit] media=%s', prompt[:50], video_media_id[:12])
+ result = await bridge.api_request(ENDPOINTS["generate_edit"], body)
+
+ status = result.get("status", 0)
+ if status != 200:
+ err = result.get("data", {})
+ if isinstance(err, dict):
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
+ log.error("❌ Failed (%s): %s", status, err)
+ return None
+
+ data = result.get("data", {})
+ media = data.get("media", [])
+ if not media:
+ log.error("❌ No media in response")
+ return None
+
+ media_ids = [m.get("name") for m in media]
+ credits = data.get("remainingCredits", "?")
+ log.info("✅ Edit submitted! %d video(s), credits=%s", len(media_ids), credits)
+ return media_ids
diff --git a/flow-agent/omniflash/media_store.py b/flow-agent/omniflash/media_store.py
new file mode 100644
index 0000000000000000000000000000000000000000..db30b4b341f08a3dbdcefd7dd66c588e0ca5ea5d
--- /dev/null
+++ b/flow-agent/omniflash/media_store.py
@@ -0,0 +1,45 @@
+"""Omni Flash — Media ID store.
+
+Reads and writes media-id.js (filename → media_id mapping).
+Single source of truth — used by upload, upload_image, etc.
+"""
+
+import logging
+import os
+
+from .config import MEDIA_ID_FILE
+
+log = logging.getLogger("omniflash.media_store")
+
+
+def read_entries() -> dict[str, str]:
+ """Read all filename → media_id entries from media-id.js."""
+ entries = {}
+ if os.path.exists(MEDIA_ID_FILE):
+ with open(MEDIA_ID_FILE, "r") as f:
+ for line in f:
+ line = line.strip()
+ if " : " in line:
+ k, v = line.split(" : ", 1)
+ entries[k.strip()] = v.strip()
+ return entries
+
+
+def write_entries(entries: dict[str, str]):
+ """Write all entries to media-id.js (sorted)."""
+ with open(MEDIA_ID_FILE, "w") as f:
+ for k, v in sorted(entries.items()):
+ f.write(f"{k} : {v}\n")
+
+
+def save(filename: str, media_id: str):
+ """Add or update a single entry in media-id.js."""
+ entries = read_entries()
+ entries[filename] = media_id
+ write_entries(entries)
+ log.info("📝 Updated media-id.js: %s → %s", filename, media_id)
+
+
+def get(filename: str) -> str | None:
+ """Get media_id for a filename, or None."""
+ return read_entries().get(filename)
diff --git a/flow-agent/omniflash/upload.py b/flow-agent/omniflash/upload.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f4d22ca0cd4fc4a9f89d71de64cd8af4d457813
--- /dev/null
+++ b/flow-agent/omniflash/upload.py
@@ -0,0 +1,116 @@
+"""Omni Flash — Video upload via GCS resumable upload.
+
+Uploads a video file to Google Flow using the tRPC session URL
+and curl for the binary PUT.
+"""
+
+import asyncio
+import json
+import logging
+import os
+import subprocess
+import uuid
+
+from .bridge import ExtensionBridge
+from . import media_store
+
+log = logging.getLogger("omniflash.upload")
+
+DEFAULT_PROJECT_ID = "ff92d5cc-8a03-41d2-b59e-e0774d17bcf6"
+
+
+async def upload_video(video_path: str, project_id: str = DEFAULT_PROJECT_ID,
+ bridge: ExtensionBridge = None) -> dict:
+ """Upload a video file to Google Flow.
+
+ If bridge is provided, uses it (caller manages lifecycle).
+ Otherwise creates and closes its own bridge.
+
+ Returns dict with mediaId, media, workflow on success.
+ """
+ video_path = os.path.abspath(video_path)
+ if not os.path.exists(video_path):
+ raise FileNotFoundError(f"Video not found: {video_path}")
+
+ video_size = os.path.getsize(video_path)
+ log.info("Video: %s (%d bytes, %.1fMB)",
+ os.path.basename(video_path), video_size, video_size / 1024 / 1024)
+
+ # Manage bridge lifecycle
+ own_bridge = bridge is None
+ if own_bridge:
+ bridge = ExtensionBridge()
+ await bridge.start()
+ if not await bridge.wait_for_extension(timeout=30):
+ raise ConnectionError("Extension did not connect")
+ await asyncio.sleep(2)
+
+ # Step 1: Get session URL via extension's trpc_request
+ token = bridge._flow_key
+ rid = str(uuid.uuid4())[:8]
+ fut = asyncio.get_event_loop().create_future()
+ bridge._pending[rid] = fut
+
+ await bridge._ws.send(json.dumps({
+ "id": rid,
+ "method": "trpc_request",
+ "params": {
+ "url": "https://labs.google/fx/api/upload-video?action=start",
+ "method": "POST",
+ "headers": {
+ "X-Upload-Project-Id": project_id,
+ "X-Upload-Content-Type": "video/mp4",
+ "X-Upload-Content-Length": str(video_size),
+ },
+ },
+ }))
+
+ try:
+ r = await asyncio.wait_for(fut, timeout=20)
+ except asyncio.TimeoutError:
+ if own_bridge:
+ await bridge.close()
+ raise TimeoutError("Step 1 timeout")
+
+ if own_bridge:
+ await bridge.close()
+
+ session_url = r.get("data", {}).get("sessionUrl", "")
+ auth_token = token or ""
+
+ if not session_url:
+ raise RuntimeError(f"No session URL: {json.dumps(r)[:500]}")
+
+ log.info("Got session URL")
+
+ # Step 2: PUT via curl
+ log.info("Uploading %d bytes via curl...", video_size)
+ proc = subprocess.run([
+ "curl", "-X", "PUT", session_url,
+ "-H", "Content-Type: video/mp4",
+ "-H", f"Authorization: Bearer {auth_token}",
+ "-H", "X-Goog-Upload-Command: upload, finalize",
+ "-H", "X-Goog-Upload-Offset: 0",
+ "--data-binary", f"@{video_path}",
+ "--max-time", "120",
+ ], capture_output=True, text=True)
+
+ if proc.returncode != 0:
+ raise RuntimeError(f"curl failed: {proc.stderr[:500]}")
+
+ if not proc.stdout.strip():
+ raise RuntimeError("Empty response from upload")
+
+ data = json.loads(proc.stdout)
+ media_id = data.get("mediaId") or data.get("name") or data.get("id")
+ if not media_id and isinstance(data.get("media"), dict):
+ media_id = data["media"].get("name") or data["media"].get("mediaId")
+
+ if media_id:
+ log.info("✅ SUCCESS! media_id = %s", media_id)
+ media_store.save(os.path.basename(video_path), media_id)
+ else:
+ log.warning("Upload succeeded but no media_id found")
+ log.info("Response: %s", json.dumps(data, indent=2)[:1000])
+
+ return data
diff --git a/flow-agent/omniflash/watermark.py b/flow-agent/omniflash/watermark.py
new file mode 100644
index 0000000000000000000000000000000000000000..dcaa66751ae47633826b9a35ecaa6fdb3bc83663
--- /dev/null
+++ b/flow-agent/omniflash/watermark.py
@@ -0,0 +1,375 @@
+"""Omni Flash — Video Watermark Remover.
+
+Removes Gemini/Flow watermark from generated videos using reverse alpha blending.
+Ported from Gemini-Watermark-Remover-UI (TypeScript → Python).
+
+Algorithm:
+ Gemini adds watermark: watermarked = α × logo + (1 - α) × original
+ Reverse solve: original = (watermarked - α × logo) / (1 - α)
+"""
+
+import logging
+import os
+import cv2
+import numpy as np
+import subprocess
+import json
+import sys
+
+log = logging.getLogger("omniflash.watermark")
+
+# ── Constants ──────────────────────────────────────────────
+ALPHA_THRESHOLD = 0.002 # Ignore very small alpha values (noise)
+MAX_ALPHA = 0.99 # Avoid division by near-zero values
+LOGO_VALUE = 255 # White watermark color value
+VIDEO_ALPHA_SCALE = 0.6 # Gemini videos use 60% opacity watermark
+
+
+# ── Embedded watermark assets (base64-encoded PNGs) ──
+_BG_48_B64 = "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"
+
+_BG_96_B64 = "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"
+
+
+def _load_alpha_map(size: int) -> np.ndarray:
+ """Load alpha map from embedded base64 PNG data.
+
+ Alpha map is the max RGB channel of the watermark on black background,
+ normalized to [0, 1].
+ """
+ import base64
+
+ b64 = _BG_48_B64 if size == 48 else _BG_96_B64
+ png_bytes = base64.b64decode(b64)
+ arr = np.frombuffer(png_bytes, dtype=np.uint8)
+ bg = cv2.imdecode(arr, cv2.IMREAD_COLOR)
+
+ if bg is None:
+ raise ValueError(f"Failed to decode embedded bg_{size}.png")
+
+ # Resize to exact size if needed
+ if bg.shape[0] != size or bg.shape[1] != size:
+ bg = cv2.resize(bg, (size, size))
+
+ # Max of RGB channels, normalized to [0, 1]
+ alpha_map = bg.max(axis=2).astype(np.float32) / 255.0
+ return alpha_map
+
+
+# Cache alpha maps
+_alpha_cache: dict[int, np.ndarray] = {}
+
+
+def get_alpha_map(size: int) -> np.ndarray:
+ """Get cached alpha map for given watermark size."""
+ if size not in _alpha_cache:
+ _alpha_cache[size] = _load_alpha_map(size)
+ return _alpha_cache[size]
+
+
+def detect_watermark_config(width: int, height: int, is_video: bool = True):
+ """Detect watermark size and position based on dimensions."""
+ if is_video:
+ short_dim = min(width, height)
+ if short_dim >= 1080:
+ logo_size = 96
+ margin_right = 64
+ margin_bottom = 64
+ else:
+ logo_size = 48
+ margin_right = 72
+ margin_bottom = 72
+ else:
+ if width == 720 and height == 1280:
+ logo_size = 48
+ margin_right = 72
+ margin_bottom = 72
+ else:
+ short_dim = min(width, height)
+ if short_dim > 800:
+ logo_size = 96
+ margin_right = 64
+ margin_bottom = 64
+ else:
+ logo_size = 48
+ margin_right = 32
+ margin_bottom = 32
+
+ x = width - margin_right - logo_size
+ y = height - margin_bottom - logo_size
+
+ return {
+ "logo_size": logo_size,
+ "x": x,
+ "y": y,
+ "width": logo_size,
+ "height": logo_size,
+ }
+
+
+def remove_watermark_frame(frame: np.ndarray, is_video: bool = True) -> np.ndarray:
+ """Remove watermark from a single frame (BGR numpy array).
+
+ Uses reverse alpha blending:
+ original = (watermarked - α × logo) / (1 - α)
+ """
+ h, w = frame.shape[:2]
+ config = detect_watermark_config(w, h, is_video)
+ alpha_map = get_alpha_map(config["logo_size"])
+
+ x, y = config["x"], config["y"]
+ size = config["logo_size"]
+
+ # Extract watermark region
+ region = frame[y:y+size, x:x+size].astype(np.float32)
+
+ # Scale alpha for video
+ alpha = alpha_map.copy()
+ if is_video:
+ alpha = alpha * VIDEO_ALPHA_SCALE
+
+ # Create mask for significant alpha values
+ mask = alpha >= ALPHA_THRESHOLD
+ alpha_clamped = np.clip(alpha, 0, MAX_ALPHA)
+ one_minus_alpha = 1.0 - alpha_clamped
+
+ # Apply reverse alpha blending to each channel
+ for c in range(3): # BGR
+ channel = region[:, :, c]
+ # original = (watermarked - α × LOGO_VALUE) / (1 - α)
+ original = (channel - alpha_clamped * LOGO_VALUE) / one_minus_alpha
+ # Only modify pixels where alpha is significant
+ channel[mask] = original[mask]
+ region[:, :, c] = np.clip(channel, 0, 255)
+
+ # Write back
+ frame[y:y+size, x:x+size] = region.astype(np.uint8)
+ return frame
+
+
+def remove_watermark_yuv_frame(frame_bytes: bytes, width: int, height: int, config: dict) -> bytes:
+ """Apply YUV watermark reverse blending directly in raw yuv420p buffer using NumPy."""
+ frame_arr = np.frombuffer(frame_bytes, dtype=np.uint8).copy()
+
+ y_size = width * height
+ uv_size = (width // 2) * (height // 2)
+
+ # Reshape planes into 2D views
+ Y = frame_arr[0:y_size].reshape((height, width))
+ U = frame_arr[y_size:y_size + uv_size].reshape((height // 2, width // 2))
+ V = frame_arr[y_size + uv_size:].reshape((height // 2, width // 2))
+
+ x, y = config["x"], config["y"]
+ size = config["logo_size"]
+
+ # Align coordinates to even numbers for YUV420p subsampling compatibility
+ x0 = (x // 2) * 2
+ y0 = (y // 2) * 2
+ x1 = ((x + size + 1) // 2) * 2
+ y1 = ((y + size + 1) // 2) * 2
+
+ # Ensure bounds are within image dimensions (which must also be even)
+ x0 = max(0, min(x0, width))
+ y0 = max(0, min(y0, height))
+ x1 = max(0, min(x1, width))
+ y1 = max(0, min(y1, height))
+
+ # Intersect aligned bounds with actual watermark bounding box
+ w_x0, w_y0 = x, y
+ w_x1, w_y1 = x + size, y + size
+
+ int_x0 = max(x0, w_x0)
+ int_y0 = max(y0, w_y0)
+ int_x1 = min(x1, w_x1)
+ int_y1 = min(y1, w_y1)
+
+ # Create target alpha map for coordinate-aligned region
+ alpha_map_full = np.zeros((y1 - y0, x1 - x0), dtype=np.float32)
+
+ if int_x1 > int_x0 and int_y1 > int_y0:
+ src_x0 = int_x0 - w_x0
+ src_y0 = int_y0 - w_y0
+ src_x1 = int_x1 - w_x0
+ src_y1 = int_y1 - w_y0
+
+ dst_x0 = int_x0 - x0
+ dst_y0 = int_y0 - y0
+ dst_x1 = int_x1 - x0
+ dst_y1 = int_y1 - y0
+
+ alpha_map_full[dst_y0:dst_y1, dst_x0:dst_x1] = get_alpha_map(size)[src_y0:src_y1, src_x0:src_x1]
+
+ scaled_alpha = alpha_map_full * VIDEO_ALPHA_SCALE
+ scaled_alpha = np.clip(scaled_alpha, 0, MAX_ALPHA)
+ one_minus_alpha = 1.0 - scaled_alpha
+ mask = alpha_map_full >= ALPHA_THRESHOLD
+
+ # 1. Process Y channel (Luminance)
+ Y_roi = Y[y0:y1, x0:x1].astype(np.float32)
+ new_Y = (Y_roi - scaled_alpha * 235.0) / one_minus_alpha
+ Y[y0:y1, x0:x1] = np.where(mask, np.clip(new_Y, 0, 255).astype(np.uint8), Y[y0:y1, x0:x1])
+
+ # 2. Process U & V channels (Chroma, subsampled 2x2)
+ x0_sub, y0_sub = x0 // 2, y0 // 2
+ x1_sub, y1_sub = x1 // 2, y1 // 2
+
+ scaled_alpha_sub = scaled_alpha[::2, ::2]
+ one_minus_alpha_sub = one_minus_alpha[::2, ::2]
+ mask_sub = mask[::2, ::2]
+
+ # U Channel
+ U_roi = U[y0_sub:y1_sub, x0_sub:x1_sub].astype(np.float32)
+ new_U = (U_roi - 128.0) / one_minus_alpha_sub + 128.0
+ U[y0_sub:y1_sub, x0_sub:x1_sub] = np.where(mask_sub, np.clip(new_U, 0, 255).astype(np.uint8), U[y0_sub:y1_sub, x0_sub:x1_sub])
+
+ # V Channel
+ V_roi = V[y0_sub:y1_sub, x0_sub:x1_sub].astype(np.float32)
+ new_V = (V_roi - 128.0) / one_minus_alpha_sub + 128.0
+ V[y0_sub:y1_sub, x0_sub:x1_sub] = np.where(mask_sub, np.clip(new_V, 0, 255).astype(np.uint8), V[y0_sub:y1_sub, x0_sub:x1_sub])
+
+ return frame_arr.tobytes()
+
+
+def remove_watermark_video(input_path: str, output_path: str = None,
+ show_progress: bool = True) -> str:
+ """Remove watermark from a video file using ffmpeg YUV420p pipes.
+
+ Args:
+ input_path: Path to input video file
+ output_path: Path for output video (default: adds _clean suffix)
+ show_progress: Show progress log
+
+ Returns:
+ Path to cleaned video file
+ """
+ input_path = str(input_path)
+ if output_path is None:
+ base, ext = os.path.splitext(input_path)
+ output_path = f"{base}_clean{ext}"
+
+ abs_input = os.path.abspath(input_path)
+ abs_output = os.path.abspath(output_path)
+
+ # Get video info via ffprobe
+ probe = subprocess.run(
+ ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", abs_input],
+ capture_output=True, text=True
+ )
+ info = json.loads(probe.stdout)
+
+ video_stream = next(s for s in info["streams"] if s["codec_type"] == "video")
+ width = int(video_stream["width"])
+ height = int(video_stream["height"])
+
+ # Parse fps
+ fps_str = video_stream.get("r_frame_rate", "24/1")
+ if "/" in fps_str:
+ num, den = fps_str.split("/")
+ fps = float(num) / float(den)
+ else:
+ fps = float(fps_str)
+
+ duration = float(info.get("format", {}).get("duration", 0))
+ total_frames = int(duration * fps) if duration > 0 else 0
+
+ log.info("🎬 Processing YUV video: %dx%d @ %.1f fps (%d frames)", width, height, fps, total_frames)
+
+ config = detect_watermark_config(width, height, is_video=True)
+
+ # Temp file for safe rewrite
+ temp_out = abs_output + ".tmp.mp4"
+ if os.path.exists(temp_out):
+ os.remove(temp_out)
+
+ # Dynamic macOS Hardware Acceleration vs standard CPU fallback
+ if sys.platform == "darwin":
+ read_cmd = [
+ "ffmpeg", "-hwaccel", "videotoolbox", "-i", abs_input,
+ "-f", "rawvideo", "-pix_fmt", "yuv420p",
+ "-v", "quiet", "-"
+ ]
+ encoder_args = ["-c:v", "h264_videotoolbox", "-b:v", "4000k"]
+ else:
+ read_cmd = [
+ "ffmpeg", "-i", abs_input,
+ "-f", "rawvideo", "-pix_fmt", "yuv420p",
+ "-v", "quiet", "-"
+ ]
+ encoder_args = ["-c:v", "libx264", "-preset", "fast", "-crf", "18"]
+
+ write_cmd = [
+ "ffmpeg", "-y",
+ "-f", "rawvideo", "-pix_fmt", "yuv420p",
+ "-s", f"{width}x{height}", "-r", str(fps_str),
+ "-i", "-",
+ "-i", abs_input,
+ "-map", "0:v", "-map", "1:a?",
+ ] + encoder_args + [
+ "-c:a", "copy",
+ "-movflags", "+faststart",
+ "-v", "quiet",
+ temp_out
+ ]
+
+ reader = subprocess.Popen(read_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ writer = subprocess.Popen(write_cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
+
+ y_size = width * height
+ uv_size = (width // 2) * (height // 2)
+ frame_size = y_size + 2 * uv_size
+ frame_count = 0
+
+ try:
+ while True:
+ raw = reader.stdout.read(frame_size)
+ if len(raw) < frame_size:
+ break
+
+ clean_raw = remove_watermark_yuv_frame(raw, width, height, config)
+ writer.stdin.write(clean_raw)
+
+ frame_count += 1
+ if show_progress and frame_count % 30 == 0:
+ pct = int(frame_count / total_frames * 100) if total_frames > 0 else 0
+ log.info(" Processing: %d%% (%d/%d frames)", pct, frame_count, total_frames)
+ finally:
+ writer.stdin.close()
+ writer.wait()
+ reader.wait()
+
+ if os.path.exists(temp_out):
+ if os.path.exists(abs_output):
+ os.remove(abs_output)
+ os.rename(temp_out, abs_output)
+ else:
+ raise RuntimeError("Failed to compile clean watermark-free video")
+
+ size_mb = os.path.getsize(abs_output) / (1024 * 1024)
+ log.info("✅ Watermark removed: %s (%.1f MB, %d frames)", abs_output, size_mb, frame_count)
+ return abs_output
+
+
+def remove_watermark_image(input_path: str, output_path: str = None) -> str:
+ """Remove watermark from a single image file.
+
+ Args:
+ input_path: Path to input image
+ output_path: Path for output (default: adds _clean suffix)
+
+ Returns:
+ Path to cleaned image
+ """
+ input_path = str(input_path)
+ if output_path is None:
+ base, ext = os.path.splitext(input_path)
+ output_path = f"{base}_clean{ext}"
+
+ img = cv2.imread(input_path)
+ if img is None:
+ raise ValueError(f"Cannot read image: {input_path}")
+
+ clean = remove_watermark_frame(img, is_video=False)
+ cv2.imwrite(output_path, clean)
+
+ log.info("✅ Watermark removed: %s", output_path)
+ return output_path
diff --git a/flow-agent/requirements.txt b/flow-agent/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a50d7969623505af83cb5c9d683a828db693778a
--- /dev/null
+++ b/flow-agent/requirements.txt
@@ -0,0 +1,7 @@
+websockets>=12.0
+opencv-python-headless>=4.8
+numpy>=1.24
+fastapi>=0.100.0
+uvicorn>=0.22.0
+python-multipart>=0.0.6
+
diff --git a/flow-agent/test_api.py b/flow-agent/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce79b7ca35d1e78abf84f6d8ad5da4913fc4eb2c
--- /dev/null
+++ b/flow-agent/test_api.py
@@ -0,0 +1,319 @@
+#!/usr/bin/env python3
+"""Integration Test Script for Flow Agent API.
+
+Tests the following end-to-end flow:
+1. GET /health
+2. POST /generate/image (T2I)
+3. POST /upload/image
+4. POST /generate/video (I2V)
+5. POST /upload/video
+6. POST /edit/video (V2V)
+"""
+
+import os
+import sys
+import time
+
+# Add virtual environment site-packages to sys.path to run with system python
+venv_site_packages = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "lib", "python3.14", "site-packages")
+if os.path.exists(venv_site_packages):
+ sys.path.insert(0, venv_site_packages)
+
+# Clear proxy environment variables to bypass sandbox proxy for local requests
+os.environ.pop("HTTP_PROXY", None)
+os.environ.pop("HTTPS_PROXY", None)
+os.environ.pop("http_proxy", None)
+os.environ.pop("https_proxy", None)
+
+# Auto-install requests if not present
+try:
+ import requests
+except ImportError:
+ print("📦 Installing 'requests' library for testing...")
+ os.system(f"{sys.executable} -m pip install requests")
+ import requests
+
+API_URL = "http://localhost:8000"
+
+def log_step(name):
+ print(f"\n{'=' * 60}")
+ print(f"🚀 STEP: {name}")
+ print(f"{'=' * 60}")
+
+def check_health():
+ log_step("Checking Server Health")
+ try:
+ r = requests.get(f"{API_URL}/health")
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+ if r.status_code != 200:
+ print("❌ Server health check failed!")
+ sys.exit(1)
+ data = r.json()
+ if not data.get("extension_connected"):
+ print("❌ Chrome extension is NOT connected. Please check Chrome devtools.")
+ sys.exit(1)
+ if not data.get("has_flow_key"):
+ print("⚠️ Chrome extension is connected but NOT logged in / authorized.")
+ print(" Please make sure labs.google/fx/tools/flow is open and logged in Chrome.")
+ sys.exit(1)
+ print("✅ Health Check Passed!")
+ except Exception as e:
+ print(f"❌ Failed to connect to server: {e}")
+ sys.exit(1)
+
+def generate_image():
+ log_step("Text-to-Image (T2I) Generation")
+ payload = {
+ "prompt": "a simple red apple on a clean wooden table, professional studio lighting, 8k resolution",
+ "aspect": "square",
+ "count": 1
+ }
+ r = requests.post(f"{API_URL}/generate/image", json=payload)
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ Image generation failed!")
+ sys.exit(1)
+
+ data = r.json()
+ outputs = data.get("outputs", [])
+ if not outputs:
+ print("❌ No outputs returned in image generation!")
+ sys.exit(1)
+
+ img_info = outputs[0]
+ remote_url = img_info.get("remote_url")
+ filename = img_info.get("filename")
+
+ # Download generated image locally for next steps
+ local_img_path = os.path.join("output", "test_t2i.png")
+ os.makedirs("output", exist_ok=True)
+
+ print(f"📥 Downloading image from {remote_url}...")
+ img_r = requests.get(remote_url)
+ with open(local_img_path, "wb") as f:
+ f.write(img_r.content)
+
+ print(f"✅ Saved test image to {local_img_path}")
+ return local_img_path
+
+def upload_image(image_path):
+ log_step("Uploading Image to Flow")
+ with open(image_path, "rb") as f:
+ files = {"file": (os.path.basename(image_path), f, "image/png")}
+ r = requests.post(f"{API_URL}/upload/image", files=files)
+
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ Image upload failed!")
+ sys.exit(1)
+
+ data = r.json()
+ media_id = data.get("media_id")
+ if not media_id:
+ print("❌ No media_id returned from upload!")
+ sys.exit(1)
+
+ print(f"✅ Image uploaded successfully! media_id: {media_id}")
+ return media_id
+
+def generate_image_i2i(image_media_id):
+ log_step("Image-to-Image (I2I) Generation")
+ payload = {
+ "prompt": "transform the red apple into a vibrant cartoon style, Pixar 3D aesthetic",
+ "aspect": "square",
+ "count": 1,
+ "ref": [image_media_id]
+ }
+ r = requests.post(f"{API_URL}/generate/image", json=payload)
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ Image-to-Image (I2I) generation failed!")
+ sys.exit(1)
+
+ data = r.json()
+ outputs = data.get("outputs", [])
+ if not outputs:
+ print("❌ No outputs returned in I2I generation!")
+ sys.exit(1)
+
+ img_info = outputs[0]
+ remote_url = img_info.get("remote_url")
+
+ local_i2i_path = os.path.join("output", "test_i2i.png")
+ print(f"📥 Downloading I2I image from {remote_url}...")
+ img_r = requests.get(remote_url)
+ with open(local_i2i_path, "wb") as f:
+ f.write(img_r.content)
+
+ print(f"✅ Saved I2I image to {local_i2i_path}")
+ return local_i2i_path
+
+def generate_video_t2v():
+ log_step("Text-to-Video (T2V) Generation")
+ payload = {
+ "prompt": "a beautiful golden retriever playing in a park, high quality, 8k, cinematic lighting",
+ "aspect": "portrait",
+ "duration": 4,
+ "count": 1
+ }
+ r = requests.post(f"{API_URL}/generate/video", json=payload)
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ T2V video generation failed!")
+ sys.exit(1)
+
+ data = r.json()
+ outputs = data.get("outputs", [])
+ if not outputs:
+ print("❌ No outputs returned in T2V video generation!")
+ sys.exit(1)
+
+ video_info = outputs[0]
+ download_url = video_info.get("download_url")
+
+ local_video_path = os.path.join("output", "test_t2v.mp4")
+
+ print(f"📥 Downloading video from {API_URL}{download_url}...")
+ video_r = requests.get(f"{API_URL}{download_url}")
+ with open(local_video_path, "wb") as f:
+ f.write(video_r.content)
+
+ print(f"✅ Saved T2V video to {local_video_path}")
+ return local_video_path
+
+def generate_video_i2v(image_media_id):
+ log_step("Image-to-Video (I2V) Generation")
+ payload = {
+ "prompt": "the apple slowly rolls forward, dynamic cinematic panning, highly realistic",
+ "aspect": "portrait",
+ "duration": 4, # Short duration for faster testing
+ "count": 1,
+ "start": image_media_id # Animate using the uploaded image
+ }
+ r = requests.post(f"{API_URL}/generate/video", json=payload)
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ Video generation failed!")
+ sys.exit(1)
+
+ data = r.json()
+ outputs = data.get("outputs", [])
+ if not outputs:
+ print("❌ No outputs returned in video generation!")
+ sys.exit(1)
+
+ video_info = outputs[0]
+ download_url = video_info.get("download_url")
+ filename = video_info.get("filename")
+
+ local_video_path = os.path.join("output", "test_i2v.mp4")
+
+ print(f"📥 Downloading video from {API_URL}{download_url}...")
+ video_r = requests.get(f"{API_URL}{download_url}")
+ with open(local_video_path, "wb") as f:
+ f.write(video_r.content)
+
+ print(f"✅ Saved test video to {local_video_path}")
+ return local_video_path
+
+def upload_video(video_path):
+ log_step("Uploading Video to Flow")
+ with open(video_path, "rb") as f:
+ files = {"file": (os.path.basename(video_path), f, "video/mp4")}
+ r = requests.post(f"{API_URL}/upload/video", files=files)
+
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ Video upload failed!")
+ sys.exit(1)
+
+ data = r.json()
+ media_id = data.get("media_id")
+ if not media_id:
+ print("❌ No media_id returned from upload!")
+ sys.exit(1)
+
+ print(f"✅ Video uploaded successfully! media_id: {media_id}")
+ return media_id
+
+def edit_video_v2v(video_media_id):
+ log_step("Video-to-Video (V2V) Editing")
+ payload = {
+ "prompt": "make it look like an anime sketch style, drawing pencil style",
+ "video_media_id": video_media_id,
+ "aspect": "portrait",
+ "fps": 24,
+ "duration": 4
+ }
+ r = requests.post(f"{API_URL}/edit/video", json=payload)
+ print(f"Status Code: {r.status_code}")
+ print(f"Response: {r.text}")
+
+ if r.status_code != 200:
+ print("❌ V2V video editing failed!")
+ sys.exit(1)
+
+ data = r.json()
+ outputs = data.get("outputs", [])
+ if not outputs:
+ print("❌ No outputs returned in video edit!")
+ sys.exit(1)
+
+ video_info = outputs[0]
+ download_url = video_info.get("download_url")
+
+ local_edit_path = os.path.join("output", "test_v2v.mp4")
+
+ print(f"📥 Downloading edited video from {API_URL}{download_url}...")
+ video_r = requests.get(f"{API_URL}{download_url}")
+ with open(local_edit_path, "wb") as f:
+ f.write(video_r.content)
+
+ print(f"✅ Saved edited video to {local_edit_path}")
+
+def main():
+ print("🎬 Starting Flow Agent API Integration Test...")
+
+ # 1. Health check
+ check_health()
+
+ # 2. Text to Image (T2I)
+ local_image = generate_image()
+
+ # 2b. Text to Video (T2V)
+ local_t2v_video = generate_video_t2v()
+
+ # 3. Upload Image to GCS/Flow
+ image_media_id = upload_image(local_image)
+
+ # 4. Image to Image (I2I)
+ local_i2i_image = generate_image_i2i(image_media_id)
+
+ # 5. Image to Video (I2V)
+ local_video = generate_video_i2v(image_media_id)
+
+ # 6. Upload Video to GCS/Flow
+ video_media_id = upload_video(local_video)
+
+ # 7. Video to Video (V2V)
+ edit_video_v2v(video_media_id)
+
+ print("\n" + "=" * 60)
+ print("🎉 ALL TESTS PASSED SUCCESSFULLY! Flow Agent API is 100% verified.")
+ print("=" * 60)
+
+if __name__ == "__main__":
+ main()
diff --git a/free-gemini-api/.dockerignore b/free-gemini-api/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..32453fb29c0ee0d82a5a84ee2bdb943ad0858802
--- /dev/null
+++ b/free-gemini-api/.dockerignore
@@ -0,0 +1,8 @@
+dist/
+output/
+cookies.json
+.env
+.git/
+.DS_Store
+goapi
+*.exe
diff --git a/free-gemini-api/.gitignore b/free-gemini-api/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..b1e96daf0098229ae4823f62914494fd0cd20753
--- /dev/null
+++ b/free-gemini-api/.gitignore
@@ -0,0 +1,22 @@
+# Binaries
+goapi
+*.exe
+
+# Environment & Secrets
+.env
+cookies.json
+
+# Generated media
+output/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Go
+vendor/
diff --git a/free-gemini-api/Dockerfile b/free-gemini-api/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..4c9f360813170cb30de52fc7ec681a1bdbcc2dcc
--- /dev/null
+++ b/free-gemini-api/Dockerfile
@@ -0,0 +1,21 @@
+# Build stage
+FROM golang:1.25-alpine AS builder
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o free-gemini-api .
+
+# Runtime stage
+FROM alpine:3.19
+RUN apk --no-cache add ca-certificates curl ffmpeg
+WORKDIR /app
+COPY --from=builder /app/free-gemini-api .
+RUN mkdir -p /app/output
+
+EXPOSE 8000 9222
+
+ENV WS_PORT="9222" \
+ PORT="8000"
+
+CMD ["./free-gemini-api"]
diff --git a/free-gemini-api/README.md b/free-gemini-api/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..68991c79a9f3b5ac067b6a1e56927f65e320b611
--- /dev/null
+++ b/free-gemini-api/README.md
@@ -0,0 +1,401 @@
+
+
+# 🔓 Free Gemini API
+
+### Unleash the Full Power of Google Gemini 3.5 Flash, Imagen 3, and Gemini Video — Completely Free
+
+[](https://go.dev)
+[](LICENSE)
+[](https://hub.docker.com/r/akashyadav758/free-gemini-api)
+
+**No API Keys. No Token Costs. No Complex Setup. Zero Watermarks.**
+
+*A high-performance Go reverse-proxy API server wrapping the consumer Gemini portal (`gemini.google.com`). Access text generation, multi-turn chat, image creation (Imagen 3), video creation (Gemini Video), video-to-video styling, and music generation through a simple unified REST interface.*
+
+[Architectural Benefits](#-architectural-benefits) • [Key Features](#-key-features) • [Quick Start](#-quick-start) • [API Endpoints](#-api-endpoints) • [SDK Integration](#-sdk-integration) • [Architecture](#-architecture)
+
+
+
+---
+
+## 🚀 Architectural Benefits
+
+This project is engineered to solve the real-world operational challenges of web-scraping and reverse-proxying consumer endpoints, delivering production-grade reliability:
+
+### 🎨 In-Place Reverse Alpha-Blending Watermark Remover
+Google adds visual watermarks to the bottom-right corner of all generated images and videos. The Go API server includes a highly optimized, fully automated native Go implementation ([gemini/watermark.go](file:///Users/akashyadav/Server/Go-Map/Free-gemini-api/free-gemini-api-kodelyx/gemini/watermark.go)) that executes in-process immediately upon download.
+* **The Mathematics:** It reverses the blending equation:
+ $$\text{watermarked} = \alpha \times \text{logo} + (1 - \alpha) \times \text{original}$$
+ $${\text{original}} = \frac{\text{watermarked} - \alpha \times \text{logo}}{1 - \alpha}$$
+* **Layout Adaptability:** Dynamically identifies coordinates and dimensions based on the file aspect ratio (Portrait `720x1280` maps to a `48px` watermark; standard landscape/large scales dynamically map to a `96px` watermark), yielding perfectly clean, professional-grade media outputs without black borders or distortion.
+* **Zero Dependencies:** Completely native—runs without requiring Python 3, `numpy`, or `opencv-python`!
+
+### 🔄 403 Forbidden Auto-Redirect Bypasser
+Downloading media from Google's content servers (`googleusercontent.com`) directly often results in `403 Forbidden` errors. This client implements a custom redirect follow loop:
+* **The Pipeline:** It disables Go's default automatic HTTP redirect handler, manually intercepts HTTP `302/307` locations, and dynamically injects the user's active session cookies on *every* redirect hop (e.g. cross-domain transfers to `lh3.usercontent` and `fife.usercontent`), guaranteeing 100% download success.
+
+### 🔌 Zero-Spam Chrome Extension WebSocket Bridge
+Keeping cookies updated across server restarts is solved via our lightweight Chrome Extension (`/extension`):
+* **One-Shot Sync:** Utilizing a persistent WebSocket connection, the extension instantly syncs active cookies on startup or when the browser detects a change, completely avoiding heavy periodic background alarm polls and preserving laptop battery.
+
+### 🔍 Parser-Level Deduplication & Rapid Refusal Detection
+* **Deduplication:** The JSON response parser identifies and filters out duplicate image/video references returned by the Gemini consumer API, saving bandwidth and disk space.
+* **Refusal Detection:** Recognizes generation limit responses early (e.g., *“I can't create more videos for you today...”*), immediately terminating the long-polling routine and returning structured JSON failures instead of hanging for minutes.
+
+---
+
+## 🔥 Comparison
+
+| Feature | Standard Cloud API | **Free Gemini API** |
+|:---|:---|:---|
+| **Pricing** | Pay per token / image / video | **100% Free** |
+| **API Key Required** | Yes | **No** (Uses Cookie Session) |
+| **Video Generation** | Highly restricted (Expensive) | **Gemini Video Enabled** (Standard Daily Quota) |
+| **Image Generation** | Cost per image | **Imagen 3 Enabled** (Unlimited - Uses Gemini 3.5) |
+| **Watermark Removal** | None (Or paid edit) | **Automatic In-Place Clean** |
+| **Setup Overhead** | Complex SDK configurations | **Single REST API / Docker Container** |
+
+---
+
+## ✨ Key Features
+
+* **AI Chat:** Multi-turn conversational memory with Server-Sent Events (SSE) streaming support (OpenAI-compatible `data: [DONE]` format).
+* **Imagen 3 Image Gen:** Generate stunning visuals from text prompts, perform image-to-image editing (e.g., altering clothing, backgrounds, or assets), or upload up to 10 images simultaneously for multi-image analysis.
+* **Gemini Video Gen:** Generate text-to-video clips, image-to-video reference animations, or perform complex video-to-video visual edits (e.g., turning a beach video loop into a Studio Ghibli hand-drawn anime style).
+* **Music Generation:** Produce vocal and instrumental tracks on-demand, returned as local static URL references.
+* **Automated Session Hot-Reload:** Hot-reloads session settings and tokens on cookie sync updates, allowing uninterrupted server operation.
+
+---
+
+## 🏁 Quick Start
+
+### Step 1: Install Chrome Cookie Bridge Extension
+1. Open Google Chrome and go to `chrome://extensions/`.
+2. Enable **Developer mode** (top-right toggle).
+3. Click **Load unpacked** (top-left) and select the `extension` folder located in this repository.
+4. Log into [gemini.google.com](https://gemini.google.com) on this browser instance.
+5. The extension will automatically detect your active session and sync your cookies to the Go backend WebSocket server (default port `9222`).
+
+---
+
+### Step 2: Run the Server
+
+Choose your preferred deployment method below:
+
+#### Option A: Pre-built Binary (Recommended & Easiest)
+1. Download the latest release binary matching your operating system from the GitHub Releases.
+2. Make the binary executable and launch it:
+ * **macOS / Linux:**
+ ```bash
+ chmod +x free-gemini-api-mac-arm64
+ ./free-gemini-api-mac-arm64
+ ```
+ * **Windows:** Double-click on `free-gemini-api-win-x64.exe` or run it from CMD/PowerShell.
+
+#### Option B: From Source (For Local Dev)
+Ensure you have Go 1.21+ installed on your system. Watermark removal and all endpoints run natively without any Python requirements!
+```bash
+# Run the Go server
+go run .
+```
+
+#### Option C: Docker Setup
+Run the pre-built Docker container:
+```bash
+docker run -d --name free-gemini-api \
+ -e WS_PORT=9222 \
+ -e PORT=8000 \
+ -p 8000:8000 \
+ -p 9222:9222 \
+ akashyadav758/free-gemini-api:latest
+```
+ -e WS_PORT=9222 \
+ -e PORT=8000 \
+ -p 8000:8000 \
+ -p 9222:9222 \
+ akashyadav758/free-gemini-api:latest
+```
+
+---
+
+### Step 3: Environment Variables (`.env`)
+Create a `.env` file in the root of the project to customize connection configurations:
+```env
+WS_PORT=8001 # WebSocket cookie bridge port (configured in extension)
+PORT=8000 # Port on which this Go API server runs
+```
+
+---
+
+## 📡 API Endpoints
+
+### 1. `POST /chat` — Unified Interface
+Handles text conversations, streaming, image generation, multi-image analysis, and video processing.
+
+#### parameters
+
+| Field | Type | Description |
+|:---|:---|:---|
+| `prompt` | `string` | **Required.** The instruction or text query. |
+| `user_id` | `string` | Optional. Isolated session identity. |
+| `new_chat` | `bool` | Optional. Clear conversational history context. |
+| `stream` | `bool` | Optional. Stream responses back via SSE. |
+| `image` | `file(s)` | Optional. Multi-part form image upload (supports up to 10 files). |
+
+---
+
+### End-Point Integrations & Examples
+
+#### A. Text Generation (Standard JSON)
+```bash
+curl -X POST http://localhost:8000/chat \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Explain quantum computing in three clear bullet points.",
+ "user_id": "test_user_1",
+ "new_chat": true
+ }'
+```
+
+#### B. Streaming Text Chat (Server-Sent Events)
+```bash
+curl -N -X POST http://localhost:8000/chat \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Write a short creative story about a developer and an AI assistant.",
+ "user_id": "test_user_1",
+ "stream": true
+ }'
+```
+
+#### C. Imagen 3 Image Generation (Text → Image)
+Prompt the system to generate pictures, which are automatically downloaded, cleaned of watermarks, and saved locally:
+```bash
+curl -X POST http://localhost:8000/chat \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Generate a beautiful landscape image of a sunset over snow-covered mountains, highly detailed.",
+ "user_id": "test_user_1",
+ "new_chat": true
+ }'
+```
+
+#### D. Image Editing & Variations (Image + Text Prompt)
+Upload an existing image and tell the AI to edit it in place:
+```bash
+curl -X POST http://localhost:8000/chat \
+ -F "prompt=Change the background of this image to a busy neon cyberpunk street" \
+ -F "image=@/path/to/my_photo.png" \
+ -F "user_id=test_user_1" \
+ -F "new_chat=true"
+```
+
+#### E. Gemini Video Generation (Image → Video / Text → Video)
+Provide an image reference and animate it, or generate fresh video segments from text:
+```bash
+curl -X POST http://localhost:8000/chat \
+ -F "prompt=Animate this scene: make the ocean waves crash dynamically on the shore with smooth cinematic motions" \
+ -F "image=@/path/to/beach_scene.png" \
+ -F "user_id=test_user_1" \
+ -F "new_chat=true"
+```
+
+#### F. Video-to-Video Visual Styling (Video File Input)
+Upload a video and completely rewrite its artistic style:
+```bash
+curl -X POST http://localhost:8000/chat \
+ -F "prompt=Edit this video: Convert the entire visual style of this video into a beautiful Studio Ghibli hand-drawn anime style." \
+ -F "image=@/path/to/original_video.mp4;type=video/mp4" \
+ -F "user_id=test_user_1" \
+ -F "new_chat=true"
+```
+
+---
+
+### 2. `POST /music` — Song & Audio Track Generation
+Generates audio tracks from description prompts.
+```bash
+curl -X POST http://localhost:8000/music \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "A relaxing lofi chillhop beat with smooth piano and rain sounds",
+ "user_id": "test_user_1",
+ "new_chat": true
+ }'
+```
+
+---
+
+### Response Structure (JSON Example)
+```json
+{
+ "text": "I have successfully generated your media loop based on the prompt...",
+ "conversation_id": "c_3eeb67f56f0c82f2",
+ "response_id": "r_5fd71304b0a2190b",
+ "choice_id": "rc_82705406bcc3a2f7",
+ "images": [
+ "http://localhost:8000/static/img_r_5fd71304b0a2190b_0.png"
+ ],
+ "videos": [
+ "http://localhost:8000/static/vid_r_5fd71304b0a2190b_0.mp4"
+ ],
+ "music": [],
+ "elapsed": 43.56
+}
+```
+*(All generated files served dynamically from `/static/*` have already undergone in-place watermark removal).*
+
+---
+
+## 🐍 SDK Integration
+
+### Python Example
+A production-ready script utilizing Python's `requests` library to interface with the REST server:
+
+```python
+import requests
+import json
+
+BASE_URL = "http://localhost:8000"
+
+def generate_text(prompt: str, user_id: str = "py_user"):
+ url = f"{BASE_URL}/chat"
+ payload = {
+ "prompt": prompt,
+ "user_id": user_id,
+ "new_chat": True
+ }
+ headers = {"Content-Type": "application/json"}
+
+ response = requests.post(url, json=payload, headers=headers)
+ return response.json()
+
+def edit_image_to_video(prompt: str, image_path: str, user_id: str = "py_user"):
+ url = f"{BASE_URL}/chat"
+ files = {
+ "image": (image_path, open(image_path, "rb"), "image/png")
+ }
+ data = {
+ "prompt": prompt,
+ "user_id": user_id,
+ "new_chat": "true"
+ }
+
+ response = requests.post(url, files=files, data=data)
+ return response.json()
+
+if __name__ == "__main__":
+ # Generate simple text
+ print("Sending text prompt...")
+ text_res = generate_text("Explain machine learning in one sentence.")
+ print("Response:", text_res["text"])
+
+ # Generate / Edit Image-to-Video
+ # print(edit_image_to_video("Animate a flying eagle in the clouds", "eagle.png"))
+```
+
+### Node.js Example (Streaming Support)
+An asynchronous ES6 script displaying streaming chunks using standard node-fetch:
+
+```javascript
+const BASE_URL = "http://localhost:8000";
+
+async function streamStory(prompt) {
+ const response = await fetch(`${BASE_URL}/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ prompt: prompt,
+ stream: true,
+ user_id: "node_user",
+ new_chat: true
+ })
+ });
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value);
+ // Print SSE stream lines
+ console.log(chunk);
+ }
+}
+
+streamStory("Write a short futuristic sci-fi paragraph.");
+```
+
+---
+
+## 🏗️ Architecture
+
+```
+ ┌────────────────────────┐
+ │ REST Client │
+ │ (Python, Node, cURL) │
+ └───────────┬────────────┘
+ │
+ │ HTTP REST / SSE (Port 8000)
+ ▼
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ Go API Gateway (Fiber v3 Server) │
+│ │
+│ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ ┌────────────┐ │
+│ │ Unified Chat │ │ Streaming Engine │ │ Media Down- │ │ In-Place │ │
+│ │ Endpoints │ │ (Server-Sent │ │ loader & Cookie│ │ Watermark │ │
+│ │ (POST /chat) │ │ Events) │ │ Injector │ │ Processor │ │
+│ └───────┬────────┘ └────────┬─────────┘ └────────┬───────┘ └──────┬─────┘ │
+└──────────┼───────────────────────┼────────────────────────┼────────────────────┼────────┘
+ │ │ │ │
+ │ JSON RPC payload │ Chunk Streams │ Follows manual │ Executes
+ │ │ │ redirects │ watermark.go
+ ▼ ▼ ▼ ▼
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ Google Gemini Web API Backend │
+│ (consumer.rpc.StreamGenerate / hNvQHb video poller / Imagen 3 Engine) │
+└─────────────────────────────────────────────────────────▲───────────────────────────────┘
+ │
+ │ Live Cookie Synchronization
+ │ (WebSocket Bridge Port 9222)
+ ┌────────┴────────┐
+ │ Chrome Extension│
+ │ Cookie Bridge │
+ └─────────────────┘
+```
+
+### Directory Structure
+```
+├── main.go # Fiber HTTP engine startup, Universal REST routers
+├── .env # Port and WebSocket bridge configs
+├── cookies.json # Live cookie storage, written dynamically via extension
+├── gemini/
+│ ├── client.go # Session initialization, API calls, redirect tracking
+│ ├── models.go # API schemas and structures
+│ ├── monitor.go # WebSocket bridge and cookies listener
+│ └── watermark.go # Native Go reverse alpha-blending watermark remover
+└── extension/
+ ├── manifest.json # Chrome browser extension manifest definitions
+ ├── background.js # WebSocket connection to Go backend, cookie event handlers
+ ├── popup.html # Interactive developer status panel UI
+ └── popup.js # Local settings controller
+```
+
+---
+
+## ⚠️ Limits & Notes
+
+* **Video Quota limits:** Gemini Video generation is limited to standard consumer daily accounts (~3-5 videos daily). The system returns an immediate refusal object with `videos: null` once limits are triggered.
+* **WebSocket Cookie Sync:** Ensure you load the extension located under `/extension` in your browser to automatically stream and sync active cookies to the backend Go server on port `9222`.
+
+---
+
+
+
+**⭐ Star this repository if this project helps you! ⭐**
+
+*Disclaimer: This is an unofficial proxy wrapper built for educational and personal research projects. Adhere to Google Terms of Service.*
+
+
diff --git a/free-gemini-api/extension/background.js b/free-gemini-api/extension/background.js
new file mode 100644
index 0000000000000000000000000000000000000000..fd7d1f156a3b3c2cedef936335052b17a4530059
--- /dev/null
+++ b/free-gemini-api/extension/background.js
@@ -0,0 +1,248 @@
+/**
+ * Free Gemini API Sync — Background Service Worker
+ * Proactively keeps Google Gemini cookies fresh and auto-syncs them on change.
+ */
+
+const LOCAL_WS_URL = 'ws://127.0.0.1:9222';
+let ws = null;
+let lastSyncTime = null;
+let hasSyncedOnce = false;
+let syncDebounceTimeout = null;
+let isRefreshingTab = false;
+
+chrome.runtime.onInstalled.addListener(init);
+chrome.runtime.onStartup.addListener(init);
+
+chrome.alarms.onAlarm.addListener(async (alarm) => {
+ if (alarm.name === 'reconnect') connectToBackend();
+ if (alarm.name === 'keepAlive') keepAlive();
+ if (alarm.name === 'sessionKeepAlive') {
+ console.log('[Gemini Sync] Running periodic session keep-alive refresh...');
+ ensureGeminiTabAndSync(true); // Quietly refresh session
+ }
+});
+
+async function init() {
+ connectToBackend();
+ // Keep-alive ping every 25 seconds (just ping, no cookies)
+ chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
+ // Proactively refresh Gemini session tab every 15 minutes to rotate cookies
+ chrome.alarms.create('sessionKeepAlive', { periodInMinutes: 15 });
+
+ const data = await chrome.storage.local.get(['lastSyncTime']);
+ if (data.lastSyncTime) lastSyncTime = data.lastSyncTime;
+}
+
+function connectToBackend() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ return;
+ }
+
+ console.log('[Gemini Sync] Connecting to local backend at:', LOCAL_WS_URL);
+ hasSyncedOnce = false;
+
+ try {
+ ws = new WebSocket(LOCAL_WS_URL);
+ } catch (e) {
+ console.error('[Gemini Sync] WS Connection Error:', e);
+ scheduleReconnect();
+ return;
+ }
+
+ ws.onopen = () => {
+ console.log('[Gemini Sync] Connected to Go Backend!');
+ chrome.alarms.clear('reconnect');
+ // Sync ONCE on connect
+ performSync();
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'trigger_sync') {
+ console.log('[Gemini Sync] Backend requested fresh cookies. Activating session refresh...');
+ ensureGeminiTabAndSync(false);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ ws.onclose = () => {
+ console.log('[Gemini Sync] Connection closed. Reconnecting...');
+ scheduleReconnect();
+ };
+
+ ws.onerror = (err) => {
+ console.error('[Gemini Sync] WebSocket Error:', err);
+ };
+}
+
+function scheduleReconnect() {
+ chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s
+}
+
+function keepAlive() {
+ if (ws?.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'ping' }));
+ } else {
+ connectToBackend();
+ }
+}
+
+const ALLOWED_COOKIE_NAMES = new Set([
+ '__Secure-1PSID',
+ '__Secure-3PSID',
+ '__Secure-1PAPISID',
+ '__Secure-3PAPISID',
+ '__Secure-1PSIDTS',
+ '__Secure-3PSIDTS',
+ '__Secure-1PSIDCC',
+ '__Secure-3PSIDCC',
+ 'SID',
+ 'HSID',
+ 'SSID',
+ 'APISID',
+ 'SAPISID',
+ 'SIDCC',
+ 'OSID',
+ '__Secure-OSID'
+]);
+
+// Performs the actual extraction and WebSocket transfer
+function performSync() {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+
+ chrome.cookies.getAll({}, (cookies) => {
+ const googleCookies = cookies.filter(c =>
+ (c.domain === '.google.com' || c.domain === '.google.co' || c.domain === 'gemini.google.com' || c.domain.includes('googleusercontent.com')) &&
+ (ALLOWED_COOKIE_NAMES.has(c.name) || c.domain.includes('googleusercontent.com'))
+ );
+
+ const formatted = googleCookies.map(c => {
+ let exp = c.expirationDate;
+ if (!exp || c.session) {
+ exp = Math.floor(Date.now() / 1000) + 31536000; // Default 1 year
+ }
+
+ let sameSite = c.sameSite || 'unspecified';
+ if (sameSite === 'no_restriction') sameSite = 'none';
+
+ return {
+ domain: c.domain,
+ expirationDate: exp,
+ hostOnly: c.hostOnly,
+ httpOnly: c.httpOnly,
+ name: c.name,
+ path: c.path,
+ sameSite: sameSite,
+ secure: c.secure,
+ session: c.session,
+ storeId: c.storeId || '0',
+ value: c.value
+ };
+ });
+
+ console.log(`[Gemini Sync] Syncing ${formatted.length} essential cookies to backend`);
+ ws.send(JSON.stringify({
+ type: 'cookies_payload',
+ cookies: formatted
+ }));
+
+ hasSyncedOnce = true;
+ lastSyncTime = Date.now();
+ chrome.storage.local.set({ lastSyncTime });
+ chrome.runtime.sendMessage({ type: 'SYNC_UPDATE', success: true, count: formatted.length }).catch(() => {});
+ });
+}
+
+// Proactive refresh mechanism: opens or reloads Gemini tab in background to force cookie rotation
+async function ensureGeminiTabAndSync(quietMode = false) {
+ if (isRefreshingTab) return;
+ isRefreshingTab = true;
+
+ try {
+ const tabs = await chrome.tabs.query({ url: '*://gemini.google.com/*' });
+
+ if (tabs.length > 0) {
+ console.log('[Gemini Sync] Gemini tab exists. Reloading to rotate cookies...');
+ await chrome.tabs.reload(tabs[0].id);
+ if (!quietMode) {
+ // Force focus on the existing tab to wake it up and ensure quick reload/sync
+ await chrome.tabs.update(tabs[0].id, { active: true });
+
+ // Also bring the window to the front if minimized
+ const tab = tabs[0];
+ if (tab.windowId) {
+ await chrome.windows.update(tab.windowId, { focused: true });
+ }
+ }
+ } else {
+ console.log('[Gemini Sync] No Gemini tab found. Launching a session...');
+ // Open in foreground (active: true) if not quietMode to guarantee immediate load
+ await chrome.tabs.create({ url: 'https://gemini.google.com', active: !quietMode });
+ }
+
+ // Safety timeout to reset the refreshing flag if something hangs
+ setTimeout(() => {
+ isRefreshingTab = false;
+ }, 15000);
+
+ } catch (e) {
+ console.error('[Gemini Sync] Error during session tab refresh:', e);
+ isRefreshingTab = false;
+ performSync(); // Fallback sync
+ }
+}
+
+// ─── Tab Load Listener ───────────────────────────────────────────────
+// Listen for Gemini tab loads/reloads to capture fresh cookies automatically
+chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ if (changeInfo.status === 'complete' && tab.url && tab.url.includes('gemini.google.com')) {
+ console.log('[Gemini Sync] Gemini tab loaded completely. Performing sync...');
+ performSync();
+ isRefreshingTab = false;
+ }
+});
+
+
+// ─── Real-Time Cookie Listener ──────────────────────────────────────
+// Auto-detect when Google changes or rotates session cookies and push them instantly!
+chrome.cookies.onChanged.addListener((changeInfo) => {
+ const cookie = changeInfo.cookie;
+
+ const isTargetDomain = cookie.domain === '.google.com' ||
+ cookie.domain === '.google.co' ||
+ cookie.domain === 'gemini.google.com' ||
+ cookie.domain.includes('googleusercontent.com');
+
+ if (isTargetDomain && ALLOWED_COOKIE_NAMES.has(cookie.name)) {
+ // Skip if it was deleted (unless it is a known rotation)
+ if (changeInfo.removed) return;
+
+ console.log(`[Gemini Sync] Real-time cookie updated: ${cookie.name}. Scheduling sync...`);
+
+ // Debounce multiple fast updates (since Google updates multiple cookies together)
+ if (syncDebounceTimeout) clearTimeout(syncDebounceTimeout);
+ syncDebounceTimeout = setTimeout(() => {
+ console.log('[Gemini Sync] Running debounced real-time cookie sync...');
+ performSync();
+ }, 1500);
+ }
+});
+
+// Receive message from Popup
+chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ if (msg.type === 'GET_STATUS') {
+ sendResponse({
+ connected: ws && ws.readyState === WebSocket.OPEN,
+ lastSyncTime,
+ hasSyncedOnce
+ });
+ }
+ if (msg.type === 'FORCE_SYNC') {
+ ensureGeminiTabAndSync(false);
+ sendResponse({ ok: true });
+ }
+ return true;
+});
diff --git a/free-gemini-api/extension/icon.png b/free-gemini-api/extension/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..58cadc7b72a4959816764e2c979a48d9d3933cc5
Binary files /dev/null and b/free-gemini-api/extension/icon.png differ
diff --git a/free-gemini-api/extension/manifest.json b/free-gemini-api/extension/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..70c03237458bddee00e336dd55a509337cf28155
--- /dev/null
+++ b/free-gemini-api/extension/manifest.json
@@ -0,0 +1,25 @@
+{
+ "manifest_version": 3,
+ "name": "Gemini Api Agent",
+ "version": "1.0.0",
+ "description": "Auto-syncs Google Gemini cookies to your local Free Gemini API server",
+ "permissions": ["cookies", "storage", "alarms", "tabs"],
+ "host_permissions": [
+ "*://*.google.com/*",
+ "*://*.google.co/*",
+ "*://*.googleusercontent.com/*"
+ ],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "action": {
+ "default_popup": "popup.html",
+ "default_title": "Gemini Api Agent",
+ "default_icon": "icon.png"
+ },
+ "icons": {
+ "16": "icon.png",
+ "48": "icon.png",
+ "128": "icon.png"
+ }
+}
diff --git a/free-gemini-api/extension/popup.html b/free-gemini-api/extension/popup.html
new file mode 100644
index 0000000000000000000000000000000000000000..ac3b746637bb72a46860f317b699dd8a4670e3e9
--- /dev/null
+++ b/free-gemini-api/extension/popup.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
Gemini Api Agent
+
+
+
+
+ Server Connection
+
+
+ Disconnected
+
+
+
+ Last Sync
+ Never
+
+
+
+
+
+
+
+
+
diff --git a/free-gemini-api/extension/popup.js b/free-gemini-api/extension/popup.js
new file mode 100644
index 0000000000000000000000000000000000000000..017765ca7fa4a5b3c8a25c830233d89b05253799
--- /dev/null
+++ b/free-gemini-api/extension/popup.js
@@ -0,0 +1,52 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const statusBadge = document.getElementById('status-badge');
+ const statusText = document.getElementById('status-text');
+ const lastSync = document.getElementById('last-sync');
+ const syncBtn = document.getElementById('sync-btn');
+
+ function updateUI() {
+ chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => {
+ if (chrome.runtime.lastError) return;
+ if (!response) return;
+
+ if (response.connected) {
+ statusBadge.className = 'badge connected';
+ statusText.innerText = 'Connected';
+ statusText.style.color = '#10b981';
+ } else {
+ statusBadge.className = 'badge disconnected';
+ statusText.innerText = 'Disconnected';
+ statusText.style.color = '#ef4444';
+ }
+
+ if (response.lastSyncTime) {
+ const date = new Date(response.lastSyncTime);
+ lastSync.innerText = date.toLocaleTimeString();
+ } else {
+ lastSync.innerText = 'Never';
+ }
+ });
+ }
+
+ // Initial update
+ updateUI();
+
+ // Listen for real-time updates from background service worker
+ chrome.runtime.onMessage.addListener((msg) => {
+ if (msg.type === 'SYNC_UPDATE') {
+ updateUI();
+ }
+ });
+
+ syncBtn.addEventListener('click', () => {
+ syncBtn.disabled = true;
+ syncBtn.innerText = 'Syncing...';
+ chrome.runtime.sendMessage({ type: 'FORCE_SYNC' }, () => {
+ setTimeout(() => {
+ syncBtn.disabled = false;
+ syncBtn.innerText = 'Force Sync Cookies';
+ updateUI();
+ }, 800);
+ });
+ });
+});
diff --git a/free-gemini-api/gemini/client.go b/free-gemini-api/gemini/client.go
new file mode 100644
index 0000000000000000000000000000000000000000..f6d3d671ff4ca384d34889b93c4cee04f934f8e8
--- /dev/null
+++ b/free-gemini-api/gemini/client.go
@@ -0,0 +1,1203 @@
+package gemini
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "math/rand"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ http "github.com/bogdanfinn/fhttp"
+ tls_client "github.com/bogdanfinn/tls-client"
+ "github.com/bogdanfinn/tls-client/profiles"
+)
+
+// Flash model header value (hardcoded since we only use Flash)
+const flashModelID = "56fdd199312815e2"
+
+var flashHeaderValue = fmt.Sprintf(`[1,null,null,null,"%s",null,null,0,[4],null,null,2]`, flashModelID)
+
+type GeminiClient struct {
+ client tls_client.HttpClient
+ cookiesFile string
+ SNlM0e string
+ FSID string
+ BL string
+ ReqID int
+ ConversationID string
+ ResponseID string
+ ChoiceID string
+ IsInitialized bool
+ RawCookies string
+}
+
+type CookieObject struct {
+ Domain string `json:"domain"`
+ ExpirationDate float64 `json:"expirationDate,omitempty"`
+ HostOnly bool `json:"hostOnly,omitempty"`
+ HttpOnly bool `json:"httpOnly,omitempty"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ SameSite string `json:"sameSite,omitempty"`
+ Secure bool `json:"secure,omitempty"`
+ Session bool `json:"session,omitempty"`
+ StoreId string `json:"storeId,omitempty"`
+ Value string `json:"value"`
+}
+
+func NewClient(cookiesFile string) (*GeminiClient, error) {
+ jar := tls_client.NewCookieJar()
+ options := []tls_client.HttpClientOption{
+ tls_client.WithTimeoutSeconds(300),
+ tls_client.WithClientProfile(profiles.Chrome_146),
+ tls_client.WithCookieJar(jar),
+ }
+
+ client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...)
+ if err != nil {
+ return nil, err
+ }
+
+ c := &GeminiClient{
+ client: client,
+ cookiesFile: cookiesFile,
+ ReqID: rand.Intn(9000000) + 1000000,
+ }
+
+ if err := c.loadCookies(); err != nil {
+ return nil, fmt.Errorf("failed to load cookies from %s: %w", cookiesFile, err)
+ }
+
+ return c, nil
+}
+
+func (c *GeminiClient) loadCookies() error {
+ data, err := os.ReadFile(c.cookiesFile)
+ if err != nil {
+ return err
+ }
+
+ // JSON Array Format
+ var cookieList []CookieObject
+ if err := json.Unmarshal(data, &cookieList); err == nil && len(cookieList) > 0 {
+ log.Printf("Loading cookies in JSON array format (%d cookies)", len(cookieList))
+ var rawParts []string
+ for _, ck := range cookieList {
+ domain := strings.TrimPrefix(ck.Domain, ".")
+ u, _ := url.Parse("https://" + domain)
+ c.client.GetCookieJar().SetCookies(u, []*http.Cookie{
+ {
+ Name: ck.Name,
+ Value: ck.Value,
+ Domain: ck.Domain,
+ Path: ck.Path,
+ Secure: ck.Secure,
+ },
+ })
+ rawParts = append(rawParts, fmt.Sprintf("%s=%s", ck.Name, ck.Value))
+ }
+ c.RawCookies = strings.Join(rawParts, "; ")
+ return nil
+ }
+
+ // Fallback: Legacy Object Format
+ var cookieData struct {
+ Cookies string `json:"cookies"`
+ UpdatedAt float64 `json:"updated_at"`
+ }
+
+ if err := json.Unmarshal(data, &cookieData); err == nil && cookieData.Cookies != "" {
+ log.Printf("Loading cookies in legacy string format")
+ c.RawCookies = cookieData.Cookies
+ u, _ := url.Parse("https://gemini.google.com")
+ cookies := parseCookieString(cookieData.Cookies, ".google.com")
+ c.client.GetCookieJar().SetCookies(u, cookies)
+ return nil
+ }
+
+ return fmt.Errorf("unsupported cookie format in %s", c.cookiesFile)
+}
+
+func parseCookieString(raw, domain string) []*http.Cookie {
+ var cookies []*http.Cookie
+ parts := strings.Split(raw, "; ")
+ for _, part := range parts {
+ kv := strings.SplitN(part, "=", 2)
+ if len(kv) == 2 {
+ cookies = append(cookies, &http.Cookie{
+ Name: strings.TrimSpace(kv[0]),
+ Value: strings.TrimSpace(kv[1]),
+ Domain: domain,
+ Path: "/",
+ })
+ }
+ }
+ return cookies
+}
+
+func (c *GeminiClient) InitSession() error {
+ req, err := http.NewRequest("GET", "https://gemini.google.com/app", nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
+ req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"`)
+ req.Header.Set("sec-ch-ua-arch", `"arm"`)
+ req.Header.Set("sec-ch-ua-bitness", `"64"`)
+ req.Header.Set("sec-ch-ua-full-version", `"144.0.7559.133"`)
+ req.Header.Set("sec-ch-ua-platform", `"macOS"`)
+ req.Header.Set("Referer", "https://gemini.google.com/")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("Gemini app failed: Status %d. Check if cookies are expired.", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ bodyStr := string(body)
+
+ snlm0eRe := regexp.MustCompile(`"SNlM0e":"(.*?)"`)
+ fsidRe := regexp.MustCompile(`"FdrFJe":"(.*?)"`)
+ cfb2hRe := regexp.MustCompile(`"cfb2h":"(.*?)"`)
+
+ if m := snlm0eRe.FindStringSubmatch(bodyStr); len(m) > 1 {
+ c.SNlM0e = m[1]
+ log.Printf("Session Initialized. Token size: %d", len(c.SNlM0e))
+ } else {
+ if strings.Contains(bodyStr, "ServiceLogin") || strings.Contains(bodyStr, "login.google.com") {
+ log.Println("⚠️ Session expired detected. Requesting Chrome Extension to proactively rotate cookies...")
+ BroadcastCookieRefresh()
+
+ log.Println("⏳ Sleeping 5 seconds waiting for the extension to push fresh cookies...")
+ time.Sleep(5 * time.Second)
+
+ log.Println("🔄 Reloading updated cookies...")
+ if err := c.loadCookies(); err != nil {
+ return fmt.Errorf("session expired: Google redirected to login. Failed to reload cookies: %w", err)
+ }
+
+ log.Println("🔄 Retrying InitSession with fresh cookies...")
+ return c.retryInitSession()
+ }
+ return fmt.Errorf("SNlM0e not found - Google might have changed the UI or blocked the request")
+ }
+
+ if m := fsidRe.FindStringSubmatch(bodyStr); len(m) > 1 {
+ c.FSID = m[1]
+ }
+
+ if m := cfb2hRe.FindStringSubmatch(bodyStr); len(m) > 1 {
+ c.BL = m[1]
+ }
+
+ c.IsInitialized = true
+ return nil
+}
+
+func (c *GeminiClient) retryInitSession() error {
+ req, err := http.NewRequest("GET", "https://gemini.google.com/app", nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
+ req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"`)
+ req.Header.Set("sec-ch-ua-arch", `"arm"`)
+ req.Header.Set("sec-ch-ua-bitness", `"64"`)
+ req.Header.Set("sec-ch-ua-full-version", `"144.0.7559.133"`)
+ req.Header.Set("sec-ch-ua-platform", `"macOS"`)
+ req.Header.Set("Referer", "https://gemini.google.com/")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("Gemini app failed on retry: Status %d. Check if cookies are expired.", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ bodyStr := string(body)
+
+ snlm0eRe := regexp.MustCompile(`"SNlM0e":"(.*?)"`)
+ fsidRe := regexp.MustCompile(`"FdrFJe":"(.*?)"`)
+ cfb2hRe := regexp.MustCompile(`"cfb2h":"(.*?)"`)
+
+ if m := snlm0eRe.FindStringSubmatch(bodyStr); len(m) > 1 {
+ c.SNlM0e = m[1]
+ log.Printf("Session Initialized on Retry. Token size: %d", len(c.SNlM0e))
+ } else {
+ if strings.Contains(bodyStr, "ServiceLogin") || strings.Contains(bodyStr, "login.google.com") {
+ return fmt.Errorf("session expired: Google redirected to login on retry. Please refresh cookies")
+ }
+ return fmt.Errorf("SNlM0e not found on retry - Google might have changed the UI or blocked the request")
+ }
+
+ if m := fsidRe.FindStringSubmatch(bodyStr); len(m) > 1 {
+ c.FSID = m[1]
+ }
+
+ if m := cfb2hRe.FindStringSubmatch(bodyStr); len(m) > 1 {
+ c.BL = m[1]
+ }
+
+ c.IsInitialized = true
+ return nil
+}
+
+func (c *GeminiClient) ensureInit() error {
+ if !c.IsInitialized {
+ return c.InitSession()
+ }
+ return nil
+}
+
+// executeWithRetry executes a request function. If it fails, it refreshes session and retries.
+func (c *GeminiClient) executeWithRetry(actionName string, runFunc func() error) error {
+ err := runFunc()
+ if err == nil {
+ return nil
+ }
+
+ log.Printf("⚠️ [%s] Request failed: %v. Triggering automatic cookie sync and session refresh...", actionName, err)
+
+ // Reset initialized status to force session re-initialization
+ c.IsInitialized = false
+
+ // InitSession will broadcast cookie refresh, sleep 5s, reload cookies, and retry the app session init
+ if initErr := c.InitSession(); initErr != nil {
+ log.Printf("❌ [%s] Session recovery failed: %v", actionName, initErr)
+ return fmt.Errorf("%s failed and auto-heal session recovery failed: %w", actionName, initErr)
+ }
+
+ log.Printf("🔄 [%s] Session successfully auto-healed! Retrying request...", actionName)
+ return runFunc()
+}
+
+func (c *GeminiClient) Ask(prompt string) (*GeminiResponse, error) {
+ return c.AskWithTool(prompt, "")
+}
+
+// AskStream sends a prompt and streams text chunks via callback as they arrive
+func (c *GeminiClient) AskStream(prompt string, onChunk func(text string)) (*GeminiResponse, error) {
+ start := time.Now()
+
+ var response *GeminiResponse
+ execErr := c.executeWithRetry("AskStream", func() error {
+ var err error
+ response, err = c.executeStreamRequest(prompt, onChunk)
+ return err
+ })
+
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ response.Elapsed = time.Since(start).Seconds()
+ return response, nil
+}
+
+// executeStreamRequest performs the actual streaming call (wrapped by executeWithRetry)
+func (c *GeminiClient) executeStreamRequest(prompt string, onChunk func(text string)) (*GeminiResponse, error) {
+ if err := c.ensureInit(); err != nil {
+ return nil, err
+ }
+
+ // Build request (same payload as sendRequest)
+ c.ReqID += 100
+ reqID := fmt.Sprintf("%d", c.ReqID)
+
+ msgInner := []interface{}{prompt, 0, nil, nil, nil, nil, 0}
+ msgLang := []string{"en-GB"}
+ msgContext := []interface{}{c.ConversationID, c.ResponseID, c.ChoiceID, nil, nil, nil, nil, nil, nil, ""}
+ msgStruct := []interface{}{msgInner, msgLang, msgContext}
+
+ msgJSON, _ := json.Marshal(msgStruct)
+ fReqVal := []interface{}{nil, string(msgJSON)}
+ fReqJSON, _ := json.Marshal(fReqVal)
+
+ data := url.Values{}
+ data.Set("f.req", string(fReqJSON))
+ data.Set("at", c.SNlM0e)
+
+ urlStr := "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
+
+ req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ q := req.URL.Query()
+ q.Add("bl", c.BL)
+ q.Add("_reqid", reqID)
+ q.Add("rt", "c")
+ if c.FSID != "" {
+ q.Add("f.sid", c.FSID)
+ }
+ req.URL.RawQuery = q.Encode()
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Origin", "https://gemini.google.com")
+ req.Header.Set("Referer", "https://gemini.google.com/")
+ req.Header.Set("X-Same-Domain", "1")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("API error: Status %d", resp.StatusCode)
+ }
+
+ // Read response and stream text chunks
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ rawBody := string(bodyBytes)
+
+ response := &GeminiResponse{}
+ prevText := ""
+
+ // Parse full body for chunks
+ lines := strings.Split(rawBody, "\n")
+ for i := 0; i < len(lines); i++ {
+ line := strings.TrimSpace(lines[i])
+ if !strings.HasPrefix(line, "[[\"wrb.fr\"") {
+ continue
+ }
+
+ // Parse this chunk
+ tempResp := &GeminiResponse{}
+ parseResponse(line, tempResp)
+
+ if tempResp.Text != "" && tempResp.Text != prevText {
+ // Calculate the new text delta
+ delta := tempResp.Text
+ if strings.HasPrefix(delta, prevText) {
+ delta = delta[len(prevText):]
+ }
+ if delta != "" {
+ onChunk(delta)
+ }
+ prevText = tempResp.Text
+ }
+
+ // Keep updating the response
+ if tempResp.ConversationID != "" {
+ response.ConversationID = tempResp.ConversationID
+ response.ResponseID = tempResp.ResponseID
+ response.ChoiceID = tempResp.ChoiceID
+ }
+ if tempResp.Text != "" {
+ response.Text = tempResp.Text
+ }
+ if len(tempResp.Images) > 0 {
+ response.Images = tempResp.Images
+ }
+ }
+
+ // Final full parse to catch everything
+ parseResponse(rawBody, response)
+
+ if response.ConversationID != "" {
+ c.ConversationID = response.ConversationID
+ c.ResponseID = response.ResponseID
+ c.ChoiceID = response.ChoiceID
+ }
+
+ // Video auto-detect
+ if response.ConversationID != "" &&
+ (strings.Contains(response.Text, "video_gen_chip") || strings.Contains(response.Text, "generating your video")) {
+ c.pollVideoURL(response)
+ }
+
+ if response.ConversationID == "" && response.Text == "" {
+ return nil, fmt.Errorf("empty stream response received")
+ }
+
+ return response, nil
+}
+
+func (c *GeminiClient) UploadImage(imageBytes []byte, filename string, mimeType string) (string, error) {
+ if err := c.ensureInit(); err != nil {
+ return "", err
+ }
+
+ urlStr := "https://push.clients6.google.com/upload/"
+ req, err := http.NewRequest("POST", urlStr, strings.NewReader(""))
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("x-goog-upload-protocol", "resumable")
+ req.Header.Set("x-goog-upload-command", "start")
+ req.Header.Set("x-tenant-id", "bard-storage")
+ req.Header.Set("x-goog-upload-header-content-length", fmt.Sprintf("%d", len(imageBytes)))
+ req.Header.Set("x-goog-upload-header-content-type", mimeType)
+ req.Header.Set("push-id", "feeds/mcudyrk2a4khkz")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Origin", "https://gemini.google.com")
+ req.Header.Set("Referer", "https://gemini.google.com/")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("UploadImage start failed: Status %d", resp.StatusCode)
+ }
+
+ uploadURL := resp.Header.Get("X-Goog-Upload-Url")
+ if uploadURL == "" {
+ uploadURL = resp.Header.Get("x-goog-upload-url")
+ }
+ if uploadURL == "" {
+ return "", fmt.Errorf("UploadImage start failed: missing x-goog-upload-url header")
+ }
+
+ req2, err := http.NewRequest("POST", uploadURL, strings.NewReader(string(imageBytes)))
+ if err != nil {
+ return "", err
+ }
+
+ req2.Header.Set("x-goog-upload-protocol", "resumable")
+ req2.Header.Set("x-goog-upload-command", "upload, finalize")
+ req2.Header.Set("x-goog-upload-offset", "0")
+ req2.Header.Set("x-tenant-id", "bard-storage")
+ req2.Header.Set("Content-Type", mimeType)
+ req2.Header.Set("Origin", "https://gemini.google.com")
+ req2.Header.Set("Referer", "https://gemini.google.com/")
+ req2.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+
+ resp2, err := c.client.Do(req2)
+ if err != nil {
+ return "", err
+ }
+ defer resp2.Body.Close()
+
+ if resp2.StatusCode != 200 {
+ return "", fmt.Errorf("UploadImage finalize failed: Status %d", resp2.StatusCode)
+ }
+
+ bodyBytes, _ := io.ReadAll(resp2.Body)
+ return string(bodyBytes), nil
+}
+
+// ImageInput holds data for a single image to upload
+type ImageInput struct {
+ Data []byte
+ Filename string
+ MimeType string
+}
+
+func (c *GeminiClient) AskWithImage(prompt string, imageBytes []byte, filename string, mimeType string) (*GeminiResponse, error) {
+ return c.AskWithImages(prompt, []ImageInput{{Data: imageBytes, Filename: filename, MimeType: mimeType}})
+}
+
+func (c *GeminiClient) AskWithImages(prompt string, images []ImageInput) (*GeminiResponse, error) {
+ start := time.Now()
+
+ if err := c.ensureInit(); err != nil {
+ return nil, err
+ }
+
+ // Upload all images and build imageRef array
+ var imageRef []interface{}
+ uploadErr := c.executeWithRetry("UploadImages", func() error {
+ imageRef = nil // Reset list for retry
+ for i, img := range images {
+ mediaRefPath, err := c.UploadImage(img.Data, img.Filename, img.MimeType)
+ if err != nil {
+ return err
+ }
+ mediaRefPath = strings.TrimSpace(mediaRefPath)
+ log.Printf("📸 Image %d/%d uploaded: ref=[%s]", i+1, len(images), mediaRefPath)
+
+ imageRef = append(imageRef, []interface{}{
+ []interface{}{mediaRefPath, 1, nil, img.MimeType},
+ img.Filename,
+ nil, nil, nil, nil, nil, nil,
+ []int{0},
+ })
+ }
+ return nil
+ })
+
+ if uploadErr != nil {
+ return nil, uploadErr
+ }
+
+ var response *GeminiResponse
+ var err error
+ execErr := c.executeWithRetry("AskWithImages", func() error {
+ _, response, err = c.sendRequest(prompt, "", imageRef)
+ if err != nil {
+ return err
+ }
+ if response.ConversationID == "" && response.Text == "" {
+ return fmt.Errorf("empty response received with images (possible session expiry)")
+ }
+ return nil
+ })
+
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ if response.ConversationID != "" {
+ c.ConversationID = response.ConversationID
+ c.ResponseID = response.ResponseID
+ c.ChoiceID = response.ChoiceID
+ }
+
+ // Auto-detect video generation and poll for video URL
+ if response.ConversationID != "" &&
+ (strings.Contains(response.Text, "video_gen_chip") || strings.Contains(response.Text, "generating your video") || strings.Contains(response.Text, "video is being generated")) {
+ c.pollVideoURL(response)
+ }
+
+ response.Elapsed = time.Since(start).Seconds()
+ return response, nil
+}
+
+// pollVideoURL polls hNvQHb RPC to find the video download URL
+func (c *GeminiClient) pollVideoURL(response *GeminiResponse) {
+ log.Println("🎬 Polling hNvQHb for video download URL...")
+ vidRe := regexp.MustCompile(`https?://contribution\.usercontent\.google\.com/download\??[^"\\` + "`" + `\s]+`)
+
+ for i := 0; i < 60; i++ { // 60 × 5s = 5 min max
+ time.Sleep(5 * time.Second)
+ log.Printf("🎬 Video Poll %d/60...", i+1)
+
+ hPayload := fmt.Sprintf(`["%s",10,null,1,[1],[4],null,1]`, response.ConversationID)
+ hBody, err := c.CallRPC("hNvQHb", hPayload)
+ if err != nil {
+ log.Printf("⚠️ hNvQHb error: %v", err)
+ continue
+ }
+
+ hBody = strings.ReplaceAll(hBody, `\\u0026`, "&")
+ hBody = strings.ReplaceAll(hBody, `\\u003d`, "=")
+ hBody = strings.ReplaceAll(hBody, `\u0026`, "&")
+ hBody = strings.ReplaceAll(hBody, `\u003d`, "=")
+
+ if strings.Contains(hBody, "too many requests") || strings.Contains(hBody, "a lot of requests") ||
+ strings.Contains(hBody, "try again later") || strings.Contains(hBody, "couldn't do that") ||
+ strings.Contains(hBody, "can't create more videos") || strings.Contains(hBody, "can\\'t create more videos") ||
+ strings.Contains(hBody, "I can't create") || strings.Contains(hBody, "find videos from the web") ||
+ strings.Contains(hBody, "और ज़्यादा वीडियो") || strings.Contains(hBody, "नहीं जनरेट कर सकता") ||
+ strings.Contains(hBody, "ढूँढ सकता हूँ") || strings.Contains(hBody, "limit") {
+ log.Println("❌ Video generation refused (rate limit/quota exhausted)")
+ response.Text = "Video generation failed: daily limit/quota exhausted."
+ response.Videos = nil
+ break
+ }
+
+ // Find ALL matched URLs
+ matches := vidRe.FindAllString(hBody, -1)
+ var chosenURL string
+ for _, m := range matches {
+ // Skip reference input videos uploaded by our server (which always contain "vid_")
+ if strings.Contains(m, "vid_") {
+ log.Printf("⏭️ Skipping matched reference video URL: %s", m[:min(len(m), 100)])
+ continue
+ }
+ // Skip non-video assets like protobuf context files (ensure it ends with .mp4 or contains filename=video.mp4)
+ if !strings.Contains(m, ".mp4") && !strings.Contains(m, "filename=video") {
+ log.Printf("⏭️ Skipping matched non-mp4 asset URL: %s", m[:min(len(m), 100)])
+ continue
+ }
+ chosenURL = m
+ break
+ }
+
+ if chosenURL != "" {
+ response.Videos = []string{chosenURL}
+ log.Println("✅ Video URL found!")
+ break
+ }
+
+ if strings.Contains(hBody, "Your video is ready") {
+ log.Println("📝 Video is ready but URL not found in expected format, continuing...")
+ }
+
+ // Debug: log tail of response
+ snippet := hBody
+ if len(snippet) > 200 {
+ snippet = hBody[len(hBody)-200:]
+ }
+ log.Printf("🔍 Poll response (%d chars): ...%s", len(hBody), snippet)
+ }
+}
+
+func (c *GeminiClient) AskWithTool(prompt string, tool string) (*GeminiResponse, error) {
+ start := time.Now()
+ if err := c.ensureInit(); err != nil {
+ return nil, err
+ }
+
+ var response *GeminiResponse
+ var err error
+ var rawBody string
+ execErr := c.executeWithRetry("AskWithTool", func() error {
+ rawBody, response, err = c.sendRequest(prompt, tool, nil)
+ if err != nil {
+ return err
+ }
+ if response.ConversationID == "" && response.Text == "" {
+ return fmt.Errorf("empty response received (possible session expiry)")
+ }
+ return nil
+ })
+
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ if tool == "music_gen" && rawBody != "" {
+ _ = os.WriteFile("music_raw_body.txt", []byte(rawBody), 0644)
+ log.Println("📝 Saved raw music body to music_raw_body.txt")
+ }
+
+ if response.ConversationID != "" {
+ c.ConversationID = response.ConversationID
+ c.ResponseID = response.ResponseID
+ c.ChoiceID = response.ChoiceID
+ }
+
+ // Music fallback: if URL didn't come in first response, quick poll
+ if tool == "music_gen" && len(response.Music) == 0 && response.ConversationID != "" {
+ log.Println("🎵 Music URL not in response, polling...")
+ rawConv := strings.TrimPrefix(response.ConversationID, "c_")
+ re := regexp.MustCompile(`https?://contribution\.usercontent\.google\.com/download[^"\\` + "`" + `\s]+`)
+
+ for i := 0; i < 6; i++ {
+ time.Sleep(5 * time.Second)
+ body, err := c.pollConversation(rawConv)
+ if err != nil {
+ continue
+ }
+ body = strings.ReplaceAll(body, `\u0026`, "&")
+ body = strings.ReplaceAll(body, `\u003d`, "=")
+ if m := re.FindString(body); m != "" {
+ track := MusicTrack{DownloadURL: m}
+ if idx := strings.Index(m, "filename="); idx != -1 {
+ fname := m[idx+9:]
+ if ai := strings.Index(fname, "&"); ai != -1 {
+ fname = fname[:ai]
+ }
+ fname = strings.ReplaceAll(fname, "_", " ")
+ fname = strings.TrimSuffix(strings.TrimSuffix(fname, ".mp3"), ".mp4")
+ track.Title = fname
+ }
+ response.Music = append(response.Music, track)
+ log.Println("✅ Music URL found via poll")
+ break
+ }
+ }
+ }
+
+ // Auto-detect video generation and poll
+ if tool == "" && response.ConversationID != "" &&
+ (strings.Contains(response.Text, "video_gen_chip") || strings.Contains(response.Text, "generating your video")) {
+ c.pollVideoURL(response)
+ }
+
+ response.Elapsed = time.Since(start).Seconds()
+ return response, nil
+}
+
+// AskVideo generates a video via chat prompt and polls hNvQHb for download URL.
+// Flow: sendRequest(prompt) → poll hNvQHb(convID) for contribution.usercontent URL
+func (c *GeminiClient) AskVideo(prompt string) (*GeminiResponse, error) {
+ start := time.Now()
+ if err := c.ensureInit(); err != nil {
+ return nil, err
+ }
+
+ // Ensure prompt triggers video generation
+ lower := strings.ToLower(prompt)
+ if !strings.Contains(lower, "generate a video") && !strings.Contains(lower, "create a video") && !strings.Contains(lower, "make a video") {
+ prompt = "Generate a video of: " + prompt
+ }
+
+ var response *GeminiResponse
+ var err error
+ execErr := c.executeWithRetry("AskVideo", func() error {
+ _, response, err = c.sendRequest(prompt, "", nil)
+ if err != nil {
+ return err
+ }
+ if response.ConversationID == "" && response.Text == "" {
+ return fmt.Errorf("empty response received for video (possible session expiry)")
+ }
+ return nil
+ })
+
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ if response.ConversationID != "" {
+ c.ConversationID = response.ConversationID
+ c.ResponseID = response.ResponseID
+ c.ChoiceID = response.ChoiceID
+ }
+
+ log.Printf("🎬 Video response: conv=%s, text=%s", response.ConversationID, response.Text[:min(len(response.Text), 100)])
+
+ // If no video_gen_chip in response, video wasn't triggered
+ if !strings.Contains(response.Text, "video_gen_chip") && !strings.Contains(response.Text, "generating your video") {
+ log.Println("⚠️ Video generation not triggered — Gemini didn't recognize video intent")
+ response.Elapsed = time.Since(start).Seconds()
+ return response, nil
+ }
+
+ if response.ConversationID == "" {
+ response.Elapsed = time.Since(start).Seconds()
+ return response, nil
+ }
+
+ c.pollVideoURL(response)
+
+ response.Elapsed = time.Since(start).Seconds()
+ return response, nil
+}
+
+// CallRPC sends a batchexecute request for any given RPC ID
+func (c *GeminiClient) CallRPC(rpcID, payload string) (string, error) {
+ c.ReqID += 100
+ reqID := fmt.Sprintf("%d", c.ReqID)
+
+ wrapperJSON, _ := json.Marshal([][][]interface{}{{{rpcID, payload, nil, "generic"}}})
+
+ body := url.Values{}
+ body.Set("f.req", string(wrapperJSON))
+ body.Set("at", c.SNlM0e)
+
+ urlStr := "https://gemini.google.com/_/BardChatUi/data/batchexecute"
+
+ req, err := http.NewRequest("POST", urlStr, strings.NewReader(body.Encode()))
+ if err != nil {
+ return "", err
+ }
+
+ q := req.URL.Query()
+ q.Add("bl", c.BL)
+ q.Add("_reqid", reqID)
+ q.Add("rt", "c")
+ if c.FSID != "" {
+ q.Add("f.sid", c.FSID)
+ }
+ q.Add("rpcids", rpcID)
+ q.Add("source-path", "/app")
+ req.URL.RawQuery = q.Encode()
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Origin", "https://gemini.google.com")
+ req.Header.Set("Referer", "https://gemini.google.com/")
+ req.Header.Set("X-Same-Domain", "1")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return string(bodyBytes), nil
+}
+
+// pollConversation calls hNvQHb RPC (music-style payload) for music polling
+func (c *GeminiClient) pollConversation(convID string) (string, error) {
+ payload := fmt.Sprintf(`[null,"%s"]`, convID)
+ return c.CallRPC("hNvQHb", payload)
+}
+
+func (c *GeminiClient) sendRequest(prompt string, tool string, imageRef []interface{}) (string, *GeminiResponse, error) {
+ c.ReqID += 100
+ reqID := fmt.Sprintf("%d", c.ReqID)
+
+ msgInner := []interface{}{prompt, 0, nil, imageRef, nil, nil, 0}
+ msgLang := []string{"en-GB"}
+ msgContext := []interface{}{c.ConversationID, c.ResponseID, c.ChoiceID, nil, nil, nil, nil, nil, nil, ""}
+ msgStruct := []interface{}{msgInner, msgLang, msgContext}
+
+ if tool != "" {
+ for len(msgStruct) < 33 {
+ msgStruct = append(msgStruct, nil)
+ }
+ msgStruct = append(msgStruct, []string{tool})
+ }
+
+ msgJSON, _ := json.Marshal(msgStruct)
+ fReqVal := []interface{}{nil, string(msgJSON)}
+ fReqJSON, _ := json.Marshal(fReqVal)
+
+ data := url.Values{}
+ data.Set("f.req", string(fReqJSON))
+ data.Set("at", c.SNlM0e)
+
+ urlStr := "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
+
+ req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode()))
+ if err != nil {
+ return "", nil, err
+ }
+
+ q := req.URL.Query()
+ q.Add("bl", c.BL)
+ q.Add("_reqid", reqID)
+ q.Add("rt", "c")
+ if c.FSID != "" {
+ q.Add("f.sid", c.FSID)
+ }
+ req.URL.RawQuery = q.Encode()
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Origin", "https://gemini.google.com")
+ req.Header.Set("Referer", "https://gemini.google.com/")
+ req.Header.Set("X-Same-Domain", "1")
+ req.Header.Set("x-goog-ext-525001261-jspb", flashHeaderValue)
+ req.Header.Set("x-goog-ext-525005358-jspb", `["DIRECT-API-SESSION",1]`)
+ req.Header.Set("x-goog-ext-73010989-jspb", `[0]`)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return "", nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", nil, fmt.Errorf("API error: Status %d", resp.StatusCode)
+ }
+
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ rawBody := string(bodyBytes)
+ log.Printf("Raw Body Length: %d, Response: %s", len(rawBody), rawBody[:min(len(rawBody), 500)])
+
+ response := &GeminiResponse{}
+ parseResponse(rawBody, response)
+
+ // Direct regex scan for music download URLs in raw body
+ if strings.Contains(rawBody, "contribution.usercontent.google.com") {
+ musicRe := regexp.MustCompile(`https://contribution\.usercontent\.google\.com/download[^"\\)\}\s]*`)
+ matches := musicRe.FindAllString(rawBody, -1)
+ for _, m := range matches {
+ m = strings.ReplaceAll(m, `\u0026`, "&")
+ m = strings.ReplaceAll(m, `\\u0026`, "&")
+ found := false
+ for _, existing := range response.Music {
+ if existing.DownloadURL == m {
+ found = true
+ break
+ }
+ }
+ if !found {
+ track := MusicTrack{DownloadURL: m}
+ if idx := strings.Index(m, "filename="); idx != -1 {
+ fname := m[idx+9:]
+ if ampIdx := strings.Index(fname, "&"); ampIdx != -1 {
+ fname = fname[:ampIdx]
+ }
+ fname = strings.ReplaceAll(fname, "_", " ")
+ fname = strings.TrimSuffix(fname, ".mp3")
+ fname = strings.TrimSuffix(fname, ".mp4")
+ track.Title = fname
+ }
+ response.Music = append(response.Music, track)
+ log.Printf("🎵 Found music URL via regex: %s", m[:min(len(m), 100)])
+ }
+ }
+ }
+
+ return rawBody, response, nil
+}
+
+func parseResponse(raw string, res *GeminiResponse) {
+ lines := strings.Split(raw, "\n")
+ for _, line := range lines {
+ if !strings.HasPrefix(line, "[[") {
+ continue
+ }
+
+ var wrapper [][]interface{}
+ if err := json.Unmarshal([]byte(line), &wrapper); err != nil {
+ continue
+ }
+
+ for _, item := range wrapper {
+ if len(item) < 3 {
+ continue
+ }
+ wrbID, ok := item[0].(string)
+ if !ok || wrbID != "wrb.fr" {
+ continue
+ }
+
+ payloadStr, ok := item[2].(string)
+ if !ok {
+ continue
+ }
+
+ var inner []interface{}
+ if err := json.Unmarshal([]byte(payloadStr), &inner); err != nil {
+ continue
+ }
+
+ if len(inner) > 1 {
+ if ctxArr, ok := inner[1].([]interface{}); ok && len(ctxArr) >= 2 {
+ if cid, ok := ctxArr[0].(string); ok {
+ res.ConversationID = cid
+ }
+ if rid, ok := ctxArr[1].(string); ok {
+ res.ResponseID = rid
+ }
+ }
+ }
+
+ if len(inner) > 4 {
+ if contentArr, ok := inner[4].([]interface{}); ok && len(contentArr) > 0 {
+ if msgItem, ok := contentArr[0].([]interface{}); ok && len(msgItem) > 1 {
+ if contentList, ok := msgItem[1].([]interface{}); ok && len(contentList) > 0 {
+ if text, ok := contentList[0].(string); ok {
+ res.Text = text
+ }
+ }
+ if choiceID, ok := msgItem[0].(string); ok {
+ res.ChoiceID = choiceID
+ }
+
+ if len(msgItem) > 12 {
+ if imgData, ok := msgItem[12].([]interface{}); ok && len(imgData) > 7 {
+ if outerList, ok := imgData[7].([]interface{}); ok && len(outerList) > 0 {
+ if innerList, ok := outerList[0].([]interface{}); ok {
+ for _, imgObj := range innerList {
+ if imgArr, ok := imgObj.([]interface{}); ok && len(imgArr) > 0 {
+ if subArr, ok := imgArr[0].([]interface{}); ok && len(subArr) > 3 {
+ if deepArr, ok := subArr[3].([]interface{}); ok && len(deepArr) > 3 {
+ if imgURL, ok := deepArr[3].(string); ok {
+ base := strings.Split(imgURL, "=")[0]
+ imgURL = base + "=s0"
+
+ // Deduplicate: only append if not already in res.Images
+ isDup := false
+ for _, existing := range res.Images {
+ if existing == imgURL {
+ isDup = true
+ break
+ }
+ }
+ if !isDup {
+ res.Images = append(res.Images, imgURL)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ probeMusicURLs(msgItem, res, 0)
+ }
+ }
+ }
+ }
+ }
+}
+
+// probeMusicURLs recursively probes nested data for music download URLs
+func probeMusicURLs(data interface{}, res *GeminiResponse, depth int) {
+ if depth > 20 || data == nil {
+ return
+ }
+ switch v := data.(type) {
+ case string:
+ if len(v) > 20 && strings.HasPrefix(v, "http") &&
+ strings.Contains(v, "contribution.usercontent.google.com") {
+ found := false
+ for _, m := range res.Music {
+ if m.DownloadURL == v {
+ found = true
+ break
+ }
+ }
+ if !found {
+ track := MusicTrack{DownloadURL: v}
+ if idx := strings.Index(v, "filename="); idx != -1 {
+ fname := v[idx+9:]
+ if ampIdx := strings.Index(fname, "&"); ampIdx != -1 {
+ fname = fname[:ampIdx]
+ }
+ fname = strings.ReplaceAll(fname, "_", " ")
+ fname = strings.TrimSuffix(fname, ".mp3")
+ fname = strings.TrimSuffix(fname, ".mp4")
+ track.Title = fname
+ }
+ res.Music = append(res.Music, track)
+ }
+ }
+ case []interface{}:
+ for _, item := range v {
+ probeMusicURLs(item, res, depth+1)
+ }
+ case map[string]interface{}:
+ for _, item := range v {
+ probeMusicURLs(item, res, depth+1)
+ }
+ }
+}
+
+func (c *GeminiClient) DownloadFile(urlStr, savePath string) error {
+ // Build a separate tls-client that does NOT follow redirects automatically.
+ // We follow redirects manually so we can inject Cookie header on every hop.
+ jar := tls_client.NewCookieJar()
+ dlClient, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
+ tls_client.WithTimeoutSeconds(60),
+ tls_client.WithClientProfile(profiles.Chrome_146),
+ tls_client.WithCookieJar(jar),
+ tls_client.WithNotFollowRedirects(),
+ )
+ if err != nil {
+ return fmt.Errorf("download client init failed: %w", err)
+ }
+
+ // Use CachedImageCookies (from extension bridge) if available, else RawCookies
+ cookieStr := CachedImageCookies
+ if cookieStr == "" {
+ cookieStr = c.RawCookies
+ }
+
+ currentURL := urlStr
+ for hops := 0; hops < 10; hops++ {
+ req, err := http.NewRequest("GET", currentURL, nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
+ req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
+ req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="146", "Google Chrome";v="146"`)
+ req.Header.Set("sec-ch-ua-mobile", "?0")
+ req.Header.Set("sec-ch-ua-platform", `"macOS"`)
+ req.Header.Set("Referer", "https://gemini.google.com/")
+
+ // Inject cookies on EVERY hop (this is the key fix for 403)
+ if cookieStr != "" {
+ req.Header.Set("Cookie", cookieStr)
+ }
+
+ resp, err := dlClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("download request failed: %w", err)
+ }
+
+ // Handle redirects manually
+ if resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 303 || resp.StatusCode == 307 || resp.StatusCode == 308 {
+ location := resp.Header.Get("Location")
+ resp.Body.Close()
+ if location == "" {
+ return fmt.Errorf("redirect with no Location header (status %d)", resp.StatusCode)
+ }
+ log.Printf("🔀 Download redirect %d: %s → %s", resp.StatusCode, currentURL[:min(len(currentURL), 60)], location[:min(len(location), 80)])
+ currentURL = location
+ continue
+ }
+
+ if resp.StatusCode != 200 {
+ resp.Body.Close()
+ return fmt.Errorf("download failed: Status %d at %s", resp.StatusCode, currentURL[:min(len(currentURL), 80)])
+ }
+
+ // Success — write to file
+ dir := filepath.Dir(savePath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ resp.Body.Close()
+ return fmt.Errorf("failed to create directory %s: %w", dir, err)
+ }
+
+ out, err := os.Create(savePath)
+ if err != nil {
+ resp.Body.Close()
+ return err
+ }
+
+ _, err = io.Copy(out, resp.Body)
+ resp.Body.Close()
+ out.Close()
+ if err != nil {
+ return err
+ }
+
+ info, err := os.Stat(savePath)
+ if err != nil {
+ return err
+ }
+ if info.Size() < 1000 {
+ return fmt.Errorf("file too small (%d bytes), likely failed download", info.Size())
+ }
+
+ // Run watermark remover if file is image or video
+ ext := strings.ToLower(filepath.Ext(savePath))
+ var fileType string
+ if ext == ".png" || ext == ".jpg" || ext == ".jpeg" {
+ fileType = "image"
+ } else if ext == ".mp4" {
+ fileType = "video"
+ }
+
+ if fileType != "" {
+ log.Printf("🧹 Removing watermark from %s (%s) natively in Go...", savePath, fileType)
+ if err := RemoveWatermark(savePath, fileType); err != nil {
+ log.Printf("⚠️ Failed to remove watermark natively: %v", err)
+ } else {
+ log.Printf("✨ Watermark removed successfully natively in Go: %s", savePath)
+ }
+ }
+
+ return nil
+ }
+
+ return fmt.Errorf("too many redirects downloading %s", urlStr[:min(len(urlStr), 80)])
+}
+
+// ReloadSession re-reads the cookies file and marks the session to be re-initialized lazily on the next request
+func (c *GeminiClient) ReloadSession() error {
+ if err := c.loadCookies(); err != nil {
+ return fmt.Errorf("failed to reload cookies: %v", err)
+ }
+
+ // Mark as uninitialized so the next incoming request will initialize the session with fresh cookies
+ c.IsInitialized = false
+ log.Println("♻️ Cookies reloaded in memory. Session marked for lazy re-initialization.")
+ return nil
+}
diff --git a/free-gemini-api/gemini/models.go b/free-gemini-api/gemini/models.go
new file mode 100644
index 0000000000000000000000000000000000000000..fbc97f804c86a9c88cccd26290fa6f15448bf241
--- /dev/null
+++ b/free-gemini-api/gemini/models.go
@@ -0,0 +1,32 @@
+package gemini
+
+// MusicTrack holds metadata for a generated music track
+type MusicTrack struct {
+ Title string `json:"title"`
+ Album string `json:"album,omitempty"`
+ Genre string `json:"genre,omitempty"`
+ Mood string `json:"mood,omitempty"`
+ DownloadURL string `json:"download_url"`
+ LocalPath string `json:"local_path,omitempty"`
+ Duration string `json:"duration,omitempty"`
+}
+
+// GeminiResponse represents the structured response from the API
+type GeminiResponse struct {
+ Text string `json:"text"`
+ ConversationID string `json:"conversation_id"`
+ ResponseID string `json:"response_id"`
+ ChoiceID string `json:"choice_id"`
+ Images []string `json:"images"`
+ Videos []string `json:"videos"`
+ Music []MusicTrack `json:"music,omitempty"`
+ Elapsed float64 `json:"elapsed"`
+}
+
+// ChatRequest represents the incoming request payload
+type ChatRequest struct {
+ Prompt string `json:"prompt"`
+ NewChat bool `json:"new_chat"`
+ UserID string `json:"user_id"`
+ Stream bool `json:"stream"`
+}
diff --git a/free-gemini-api/gemini/monitor.go b/free-gemini-api/gemini/monitor.go
new file mode 100644
index 0000000000000000000000000000000000000000..49cd10db3538813caa27cdd9cd7133c1df610adb
--- /dev/null
+++ b/free-gemini-api/gemini/monitor.go
@@ -0,0 +1,149 @@
+package gemini
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/gorilla/websocket"
+)
+
+// CachedImageCookies holds pre-fetched cookies for image/video downloads
+var CachedImageCookies string
+
+// OnCookiesUpdated is a callback triggered when new cookies are synced
+var OnCookiesUpdated func()
+
+var (
+ activeClients []*websocket.Conn
+ activeClientsMu sync.Mutex
+)
+
+// BroadcastCookieRefresh sends a trigger_sync message to all connected Chrome Extension clients
+func BroadcastCookieRefresh() {
+ activeClientsMu.Lock()
+ defer activeClientsMu.Unlock()
+ log.Printf("📢 Broadcasting trigger_sync to %d connected Chrome Extensions...", len(activeClients))
+
+ payload := map[string]string{"type": "trigger_sync"}
+
+ // We iterate backwards so we can safely remove disconnected clients
+ for i := len(activeClients) - 1; i >= 0; i-- {
+ conn := activeClients[i]
+ err := conn.WriteJSON(payload)
+ if err != nil {
+ log.Printf("⚠️ Failed to write to extension client: %v. Removing client.", err)
+ conn.Close()
+ activeClients = append(activeClients[:i], activeClients[i+1:]...)
+ }
+ }
+}
+
+var wsUpgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ return true // Allow Chrome Extension context
+ },
+}
+
+type ExtensionMessage struct {
+ Type string `json:"type"`
+ Cookies []CookieObject `json:"cookies"`
+}
+
+// StartCookieWebSocketServer starts a local WebSocket server to receive cookies from the extension
+func StartCookieWebSocketServer(port int) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ conn, err := wsUpgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("❌ WS Upgrade failed: %v", err)
+ return
+ }
+
+ activeClientsMu.Lock()
+ activeClients = append(activeClients, conn)
+ activeClientsMu.Unlock()
+
+ defer func() {
+ conn.Close()
+ activeClientsMu.Lock()
+ for i, c := range activeClients {
+ if c == conn {
+ activeClients = append(activeClients[:i], activeClients[i+1:]...)
+ break
+ }
+ }
+ activeClientsMu.Unlock()
+ log.Println("🔌 Chrome Extension disconnected")
+ }()
+
+ log.Println("🔌 Chrome Extension connected to cookie bridge")
+
+ for {
+ _, msgBytes, err := conn.ReadMessage()
+ if err != nil {
+ log.Println("🔌 Chrome Extension disconnected")
+ break
+ }
+
+ var msg ExtensionMessage
+ if err := json.Unmarshal(msgBytes, &msg); err != nil {
+ log.Printf("❌ Failed to decode extension message: %v", err)
+ continue
+ }
+
+ if msg.Type == "ping" {
+ conn.WriteJSON(map[string]string{"type": "pong"})
+ continue
+ }
+
+ if msg.Type == "cookies_payload" && len(msg.Cookies) > 0 {
+ log.Printf("🍪 Received %d cookies from Chrome Extension", len(msg.Cookies))
+
+ // Write to cookies.json
+ data, err := json.MarshalIndent(msg.Cookies, "", " ")
+ if err != nil {
+ log.Printf("❌ Failed to marshal cookies: %v", err)
+ continue
+ }
+
+ if err := os.WriteFile("cookies.json", data, 0644); err != nil {
+ log.Printf("❌ Failed to save cookies.json: %v", err)
+ continue
+ }
+
+ // Refresh CachedImageCookies
+ var parts []string
+ for _, ck := range msg.Cookies {
+ parts = append(parts, fmt.Sprintf("%s=%s", ck.Name, ck.Value))
+ }
+ CachedImageCookies = strings.Join(parts, "; ")
+ log.Printf("🍪 Cached %d cookies for image/video downloading", len(msg.Cookies))
+
+ // Trigger callback to reload active sessions
+ if OnCookiesUpdated != nil {
+ OnCookiesUpdated()
+ }
+ }
+ }
+ })
+
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
+ log.Printf("📡 Cookie WebSocket Server listening on %s", addr)
+
+ server := &http.Server{
+ Addr: addr,
+ Handler: mux,
+ }
+
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("❌ Cookie WebSocket Server failed: %v", err)
+ }
+}
+
diff --git a/free-gemini-api/gemini/watermark.go b/free-gemini-api/gemini/watermark.go
new file mode 100644
index 0000000000000000000000000000000000000000..f7780594e1c89a906bc55549ba8f39c4e2418359
--- /dev/null
+++ b/free-gemini-api/gemini/watermark.go
@@ -0,0 +1,424 @@
+package gemini
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "log"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// Constants for mathematical reverse alpha blending
+const (
+ alphaThreshold = 0.002
+ maxAlpha = 0.99
+ logoValue = 255.0
+ videoAlphaScale = 0.6
+)
+
+// Embedded base64-encoded PNG assets for the 48px and 96px watermarks
+const (
+ bg48B64 = "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"
+ bg96B64 = "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"
+)
+
+// Global cache for decoded alpha maps
+var (
+ alphaCache48 []float32
+ alphaCache96 []float32
+)
+
+func init() {
+ // Pre-decode and cache the watermark grids
+ var err error
+ alphaCache48, err = loadAlphaMap(48)
+ if err != nil {
+ log.Printf("⚠️ Failed to load 48px alpha map: %v", err)
+ }
+ alphaCache96, err = loadAlphaMap(96)
+ if err != nil {
+ log.Printf("⚠️ Failed to load 96px alpha map: %v", err)
+ }
+}
+
+// loadAlphaMap decodes base64 PNGs and extracts the maximum RGB channel as alpha mapping
+func loadAlphaMap(size int) ([]float32, error) {
+ b64Str := bg48B64
+ if size == 96 {
+ b64Str = bg96B64
+ }
+
+ data, err := base64.StdEncoding.DecodeString(b64Str)
+ if err != nil {
+ return nil, err
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+
+ bounds := img.Bounds()
+ w, h := bounds.Dx(), bounds.Dy()
+
+ // Resize using simple nearest-neighbor if dimensions mismatch
+ alphaMap := make([]float32, size*size)
+ for y := 0; y < size; y++ {
+ srcY := int(float64(y) * float64(h) / float64(size))
+ for x := 0; x < size; x++ {
+ srcX := int(float64(x) * float64(w) / float64(size))
+ r, g, b, _ := img.At(bounds.Min.X+srcX, bounds.Min.Y+srcY).RGBA()
+
+ // Max of RGB, scale [0, 65535] down to [0.0, 1.0]
+ maxVal := r
+ if g > maxVal {
+ maxVal = g
+ }
+ if b > maxVal {
+ maxVal = b
+ }
+
+ alphaMap[y*size+x] = float32(maxVal) / 65535.0
+ }
+ }
+
+ return alphaMap, nil
+}
+
+// WatermarkConfig holds dimensions and offset coordinates
+type WatermarkConfig struct {
+ LogoSize int
+ X int
+ Y int
+}
+
+// DetectWatermarkConfig returns position configurations based on dimensions
+func DetectWatermarkConfig(width, height int, isVideo bool) WatermarkConfig {
+ var logoSize, marginRight, marginBottom int
+
+ if isVideo {
+ shortDim := width
+ if height < shortDim {
+ shortDim = height
+ }
+
+ if shortDim >= 1080 {
+ logoSize = 96
+ marginRight = 64
+ marginBottom = 64
+ } else {
+ logoSize = 48
+ marginRight = 72
+ marginBottom = 72
+ }
+ } else {
+ // Portrait standard matching
+ if width == 720 && height == 1280 {
+ logoSize = 48
+ marginRight = 72
+ marginBottom = 72
+ } else {
+ shortDim := width
+ if height < shortDim {
+ shortDim = height
+ }
+
+ if shortDim > 800 {
+ logoSize = 96
+ marginRight = 64
+ marginBottom = 64
+ } else {
+ logoSize = 48
+ marginRight = 32
+ marginBottom = 32
+ }
+ }
+ }
+
+ x := width - marginRight - logoSize
+ y := height - marginBottom - logoSize
+
+ return WatermarkConfig{
+ LogoSize: logoSize,
+ X: x,
+ Y: y,
+ }
+}
+
+// RemoveWatermark native hub. Dispatches based on file extensions.
+func RemoveWatermark(savePath string, fileType string) error {
+ ext := strings.ToLower(filepath.Ext(savePath))
+ if ext == ".png" || ext == ".jpg" || ext == ".jpeg" {
+ return removeWatermarkFromImage(savePath)
+ } else if ext == ".mp4" {
+ return removeWatermarkFromVideo(savePath)
+ }
+ return fmt.Errorf("unsupported watermark file type: %s", ext)
+}
+
+// removeWatermarkFromImage performs native Go reverse-alpha editing on static images
+func removeWatermarkFromImage(imagePath string) error {
+ file, err := os.Open(imagePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ img, format, err := image.Decode(file)
+ if err != nil {
+ return err
+ }
+ file.Close() // Close early for in-place write
+
+ bounds := img.Bounds()
+ width, height := bounds.Dx(), bounds.Dy()
+ config := DetectWatermarkConfig(width, height, false)
+
+ // Fetch cached alpha map
+ alphaMap := alphaCache48
+ if config.LogoSize == 96 {
+ alphaMap = alphaCache96
+ }
+ if len(alphaMap) == 0 {
+ return fmt.Errorf("alpha map cache for size %d is empty", config.LogoSize)
+ }
+
+ // Create writable canvas
+ canvas := image.NewRGBA(bounds)
+ draw.Draw(canvas, bounds, img, bounds.Min, draw.Src)
+
+ // Apply math formula on watermark region
+ for dy := 0; dy < config.LogoSize; dy++ {
+ py := config.Y + dy
+ if py < bounds.Min.Y || py >= bounds.Max.Y {
+ continue
+ }
+
+ for dx := 0; dx < config.LogoSize; dx++ {
+ px := config.X + dx
+ if px < bounds.Min.X || px >= bounds.Max.X {
+ continue
+ }
+
+ alpha := alphaMap[dy*config.LogoSize+dx]
+ if alpha < alphaThreshold {
+ continue
+ }
+
+ // Clamp alpha to avoid division by zero
+ if alpha > maxAlpha {
+ alpha = maxAlpha
+ }
+
+ r, g, b, a := canvas.At(px, py).RGBA()
+
+ // Scale down values to standard float32 [0.0, 255.0]
+ fR := float64(r) / 257.0
+ fG := float64(g) / 257.0
+ fB := float64(b) / 257.0
+
+ // Solve original = (watermarked - alpha * 255.0) / (1 - alpha)
+ oneMinusAlpha := 1.0 - float64(alpha)
+ newR := (fR - float64(alpha)*logoValue) / oneMinusAlpha
+ newG := (fG - float64(alpha)*logoValue) / oneMinusAlpha
+ newB := (fB - float64(alpha)*logoValue) / oneMinusAlpha
+
+ // Clamp to [0, 255]
+ cR := uint8(math.Min(math.Max(newR, 0), 255))
+ cG := uint8(math.Min(math.Max(newG, 0), 255))
+ cB := uint8(math.Min(math.Max(newB, 0), 255))
+ cA := uint8(a / 257)
+
+ canvas.SetRGBA(px, py, color.RGBA{R: cR, G: cG, B: cB, A: cA})
+ }
+ }
+
+ // Save back in-place
+ outFile, err := os.Create(imagePath)
+ if err != nil {
+ return err
+ }
+ defer outFile.Close()
+
+ if format == "png" {
+ return png.Encode(outFile, canvas)
+ }
+ return jpeg.Encode(outFile, canvas, &jpeg.Options{Quality: 95})
+}
+
+// VideoStreamInfo is mapped from ffprobe JSON output
+type VideoStreamInfo struct {
+ Streams []struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ CodecType string `json:"codec_type"`
+ RFrameRate string `json:"r_frame_rate"`
+ } `json:"streams"`
+}
+
+// removeWatermarkFromVideo runs native FFMPEG byte pipes for zero-Python video watermark removal
+func removeWatermarkFromVideo(videoPath string) error {
+ absPath, err := filepath.Abs(videoPath)
+ if err != nil {
+ return err
+ }
+
+ // 1. Get dimensions using ffprobe
+ probeCmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", absPath)
+ probeOut, err := probeCmd.Output()
+ if err != nil {
+ return fmt.Errorf("ffprobe failed (is ffmpeg installed?): %w", err)
+ }
+
+ var info VideoStreamInfo
+ if err := json.Unmarshal(probeOut, &info); err != nil {
+ return fmt.Errorf("failed to parse ffprobe json: %w", err)
+ }
+
+ var width, height int
+ var fps string
+ for _, stream := range info.Streams {
+ if stream.CodecType == "video" {
+ width = stream.Width
+ height = stream.Height
+ fps = stream.RFrameRate
+ break
+ }
+ }
+
+ if width == 0 || height == 0 {
+ return fmt.Errorf("could not extract video dimensions for: %s", videoPath)
+ }
+
+ if fps == "" {
+ fps = "24"
+ }
+
+ config := DetectWatermarkConfig(width, height, true)
+ alphaMap := alphaCache48
+ if config.LogoSize == 96 {
+ alphaMap = alphaCache96
+ }
+
+ // Setup temp path for safe in-place rewrite
+ tempOut := absPath + ".tmp.mp4"
+ defer os.Remove(tempOut)
+
+ // FFMPEG Read command: Decode frames to raw BGR24 on stdout
+ readCmd := exec.Command("ffmpeg", "-i", absPath, "-f", "rawvideo", "-pix_fmt", "bgr24", "-v", "quiet", "-")
+ stdout, err := readCmd.StdoutPipe()
+ if err != nil {
+ return err
+ }
+
+ // FFMPEG Write command: Encode BGR24 frames back into H.264 mp4, copying audio from original
+ writeCmd := exec.Command("ffmpeg", "-y",
+ "-f", "rawvideo", "-pix_fmt", "bgr24",
+ "-s", fmt.Sprintf("%dx%d", width, height), "-r", fps,
+ "-i", "-",
+ "-i", absPath, // Re-read for audio mapping
+ "-map", "0:v", "-map", "1:a?",
+ "-c:v", "libx264", "-preset", "fast", "-crf", "18",
+ "-c:a", "copy",
+ "-movflags", "+faststart",
+ "-v", "quiet",
+ tempOut,
+ )
+
+ stdin, err := writeCmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+
+ if err := readCmd.Start(); err != nil {
+ return fmt.Errorf("ffmpeg reader start failed: %w", err)
+ }
+ if err := writeCmd.Start(); err != nil {
+ return fmt.Errorf("ffmpeg writer start failed: %w", err)
+ }
+
+ frameSize := width * height * 3 // BGR24
+ frameBuf := make([]byte, frameSize)
+
+ for {
+ _, err := io.ReadFull(stdout, frameBuf)
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ break
+ }
+ if err != nil {
+ break
+ }
+
+ // Apply watermark reverse blending directly in BGR24 byte slice
+ for dy := 0; dy < config.LogoSize; dy++ {
+ py := config.Y + dy
+ if py < 0 || py >= height {
+ continue
+ }
+
+ for dx := 0; dx < config.LogoSize; dx++ {
+ px := config.X + dx
+ if px < 0 || px >= width {
+ continue
+ }
+
+ alpha := alphaMap[dy*config.LogoSize+dx]
+ if alpha < alphaThreshold {
+ continue
+ }
+
+ // Scale down watermark intensity for videos
+ scaledAlpha := alpha * videoAlphaScale
+ if scaledAlpha > maxAlpha {
+ scaledAlpha = maxAlpha
+ }
+
+ // Offset inside raw BGR24 frame byte slice
+ pixelOffset := (py*width + px) * 3
+
+ b := frameBuf[pixelOffset]
+ g := frameBuf[pixelOffset+1]
+ r := frameBuf[pixelOffset+2]
+
+ // Solve original = (watermarked - alpha * 255.0) / (1 - alpha)
+ oneMinusAlpha := 1.0 - float64(scaledAlpha)
+
+ newB := (float64(b) - float64(scaledAlpha)*logoValue) / oneMinusAlpha
+ newG := (float64(g) - float64(scaledAlpha)*logoValue) / oneMinusAlpha
+ newR := (float64(r) - float64(scaledAlpha)*logoValue) / oneMinusAlpha
+
+ // Set BGR byte values, clamped to [0, 255]
+ frameBuf[pixelOffset] = byte(math.Min(math.Max(newB, 0), 255))
+ frameBuf[pixelOffset+1] = byte(math.Min(math.Max(newG, 0), 255))
+ frameBuf[pixelOffset+2] = byte(math.Min(math.Max(newR, 0), 255))
+ }
+ }
+
+ // Write modified frame to encoder stdin pipe
+ if _, err := stdin.Write(frameBuf); err != nil {
+ break
+ }
+ }
+
+ stdin.Close()
+ writeCmd.Wait()
+ readCmd.Wait()
+
+ // Replace original video with clean copy
+ if _, err := os.Stat(tempOut); err == nil {
+ return os.Rename(tempOut, absPath)
+ }
+
+ return fmt.Errorf("failed to compile clean watermark-free video")
+}
diff --git a/free-gemini-api/go.mod b/free-gemini-api/go.mod
new file mode 100644
index 0000000000000000000000000000000000000000..c6bdc95054193f0aec43eb4eea811a5002456a8b
--- /dev/null
+++ b/free-gemini-api/go.mod
@@ -0,0 +1,36 @@
+module goapi
+
+go 1.25.7
+
+require (
+ github.com/bogdanfinn/fhttp v0.6.8
+ github.com/bogdanfinn/tls-client v1.14.0
+ github.com/gofiber/fiber/v3 v3.0.0
+ github.com/gorilla/websocket v1.5.3
+ github.com/joho/godotenv v1.5.1
+)
+
+require (
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/bdandy/go-errors v1.2.2 // indirect
+ github.com/bdandy/go-socks4 v1.2.3 // indirect
+ github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
+ github.com/bogdanfinn/utls v1.7.7-barnius // indirect
+ github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
+ github.com/gofiber/schema v1.6.0 // indirect
+ github.com/gofiber/utils/v2 v2.0.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.18.4 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
+ github.com/tinylib/msgp v1.6.3 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.69.0 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/net v0.50.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
+)
diff --git a/free-gemini-api/go.sum b/free-gemini-api/go.sum
new file mode 100644
index 0000000000000000000000000000000000000000..7753b3f9b41325e547d4dea8f54eafe3334855ea
--- /dev/null
+++ b/free-gemini-api/go.sum
@@ -0,0 +1,79 @@
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
+github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
+github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
+github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
+github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
+github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
+github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
+github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
+github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A=
+github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
+github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
+github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
+github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
+github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
+github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
+github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
+github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
+github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
+github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
+github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
+github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
+github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
+github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
+github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
+github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
+go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/free-gemini-api/main.go b/free-gemini-api/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..3490cd2202fddcf4a15550e2957af7b4905889fd
--- /dev/null
+++ b/free-gemini-api/main.go
@@ -0,0 +1,451 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "goapi/gemini"
+ "io"
+ "log"
+ "mime/multipart"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/gofiber/fiber/v3/middleware/logger"
+ "github.com/joho/godotenv"
+)
+
+var (
+ userSessions sync.Map
+)
+
+const CookiesFile = "cookies.json"
+
+func downloadAndClean(client *gemini.GeminiClient, urlStr, filename, fileType string) error {
+ tempDir := "./output/.temp"
+ os.MkdirAll(tempDir, 0755)
+ os.MkdirAll("./output", 0755)
+
+ tempPath := filepath.Join(tempDir, filename)
+ outputPath := filepath.Join("./output", filename)
+
+ // Step 1: Download to temp (which automatically cleans the watermark natively in Go!)
+ if err := client.DownloadFile(urlStr, tempPath); err != nil {
+ os.RemoveAll(tempDir)
+ return err
+ }
+
+ // Step 2: Move from temp to output folder
+ var moveErr error
+ if err := os.Rename(tempPath, outputPath); err != nil {
+ // Fallback: Copy + Delete if rename fails across different mount points
+ input, err := os.ReadFile(tempPath)
+ if err != nil {
+ moveErr = err
+ } else if err := os.WriteFile(outputPath, input, 0644); err != nil {
+ moveErr = err
+ }
+ }
+
+ // Clean up entire temp directory
+ os.RemoveAll(tempDir)
+
+ if moveErr != nil {
+ return moveErr
+ }
+
+ log.Printf("✨ Cleaned file moved to output: %s", outputPath)
+ return nil
+}
+
+func getOrCreateClient(sessionID string) (*gemini.GeminiClient, error) {
+ if client, ok := userSessions.Load(sessionID); ok {
+ return client.(*gemini.GeminiClient), nil
+ }
+
+ if _, err := os.Stat(CookiesFile); os.IsNotExist(err) {
+ log.Println("⚠️ cookies.json not found. Requesting Chrome Extension to proactively sync cookies...")
+ gemini.BroadcastCookieRefresh()
+
+ log.Println("⏳ Sleeping 5 seconds waiting for extension to sync cookies...")
+ time.Sleep(5 * time.Second)
+
+ if _, err := os.Stat(CookiesFile); os.IsNotExist(err) {
+ return nil, fmt.Errorf("cookies.json not found. Please ensure Chrome Extension is connected and syncs cookies first.")
+ }
+ }
+
+ client, err := gemini.NewClient(CookiesFile)
+ if err != nil {
+ return nil, err
+ }
+
+ userSessions.Store(sessionID, client)
+ log.Printf("New session created for: %s", sessionID)
+ return client, nil
+}
+
+func startWebSocketBridge() {
+ // Initialize callback to reload sessions when extension updates cookies
+ gemini.OnCookiesUpdated = func() {
+ log.Println("♻️ Extension pushed new cookies. Reloading active sessions...")
+ userSessions.Range(func(key, value interface{}) bool {
+ client := value.(*gemini.GeminiClient)
+ sessionID := key.(string)
+
+ if err := client.ReloadSession(); err != nil {
+ log.Printf("❌ Failed to reload session for %s: %v", sessionID, err)
+ } else {
+ log.Printf("✅ Session %s refreshed", sessionID)
+ }
+ return true
+ })
+ }
+
+ wsPortStr := os.Getenv("WS_PORT")
+ if wsPortStr == "" {
+ wsPortStr = "9222" // default port
+ }
+ wsPort, err := strconv.Atoi(wsPortStr)
+ if err != nil {
+ wsPort = 9222
+ }
+
+ // Start WebSocket Server
+ go gemini.StartCookieWebSocketServer(wsPort)
+}
+
+func main() {
+ if err := godotenv.Load(); err != nil {
+ log.Println("⚠️ No .env file found")
+ }
+
+ startWebSocketBridge()
+
+
+ app := fiber.New(fiber.Config{
+ AppName: "Gemini Go API",
+ BodyLimit: 50 * 1024 * 1024, // 50MB
+ })
+
+ // Add recovery to prevent crashes and logger for debugging
+ app.Use(logger.New())
+ app.Use(func(c fiber.Ctx) error {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("⚠️ RECOVERED from panic: %v", r)
+ c.Status(500).JSON(fiber.Map{"error": "Internal Server Error - Recovered"})
+ }
+ }()
+ return c.Next()
+ })
+
+ // Unified chat endpoint - handles text, images, video, everything
+ app.Post("/chat", func(c fiber.Ctx) error {
+ var prompt, userID string
+ var newChat, stream bool
+ var images []gemini.ImageInput
+
+ contentType := string(c.Request().Header.ContentType())
+
+ if strings.Contains(contentType, "multipart/form-data") {
+ // Multipart: image upload mode
+ prompt = c.FormValue("prompt")
+ userID = c.FormValue("user_id")
+ newChat = c.FormValue("new_chat") == "true"
+ stream = c.FormValue("stream") == "true"
+
+ form, err := c.MultipartForm()
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "Invalid multipart form"})
+ }
+
+ var fileHeaders []*multipart.FileHeader
+ if files, ok := form.File["image"]; ok {
+ fileHeaders = append(fileHeaders, files...)
+ }
+ if files, ok := form.File["images"]; ok {
+ fileHeaders = append(fileHeaders, files...)
+ }
+
+ if len(fileHeaders) > 10 {
+ return c.Status(400).JSON(fiber.Map{"error": "Maximum 10 images allowed per request"})
+ }
+
+ for _, fh := range fileHeaders {
+ file, err := fh.Open()
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": "Failed to open image file"})
+ }
+ data, err := io.ReadAll(file)
+ file.Close()
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": "Failed to read image data"})
+ }
+ mime := fh.Header.Get("Content-Type")
+ if mime == "" {
+ mime = "image/jpeg"
+ }
+ images = append(images, gemini.ImageInput{Data: data, Filename: fh.Filename, MimeType: mime})
+ }
+ } else {
+ // JSON: text-only mode
+ var req gemini.ChatRequest
+ if err := c.Bind().JSON(&req); err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
+ }
+ prompt = req.Prompt
+ userID = req.UserID
+ newChat = req.NewChat
+ stream = req.Stream
+ }
+
+ sessionID := userID
+ if sessionID == "" {
+ sessionID = c.IP()
+ }
+
+ if newChat {
+ userSessions.Delete(sessionID)
+ }
+
+ client, err := getOrCreateClient(sessionID)
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ var resp *gemini.GeminiResponse
+
+ if stream && len(images) == 0 {
+ // SSE Streaming mode (text-only)
+ c.Set("Content-Type", "text/event-stream")
+ c.Set("Cache-Control", "no-cache")
+ c.Set("Connection", "keep-alive")
+ c.Set("X-Accel-Buffering", "no")
+
+ return c.SendStreamWriter(func(w *bufio.Writer) {
+ var streamResp *gemini.GeminiResponse
+ var streamErr error
+
+ streamResp, streamErr = client.AskStream(prompt, func(chunk string) {
+ data, _ := json.Marshal(fiber.Map{"text": chunk})
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ w.Flush()
+ })
+
+ if streamErr != nil {
+ data, _ := json.Marshal(fiber.Map{"error": streamErr.Error()})
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ w.Flush()
+ return
+ }
+
+ // Download images locally
+ if len(streamResp.Images) > 0 {
+ for i, imgURL := range streamResp.Images {
+ filename := fmt.Sprintf("img_%s_%d.png", streamResp.ResponseID, i)
+ if streamResp.ResponseID == "" {
+ filename = fmt.Sprintf("img_%d_%d.png", time.Now().Unix(), i)
+ }
+ if err := downloadAndClean(client, imgURL, filename, "image"); err == nil {
+ streamResp.Images[i] = fmt.Sprintf("http://localhost:8000/output/%s", filename)
+ }
+ }
+ }
+
+ // Download videos locally
+ if len(streamResp.Videos) > 0 {
+ vidURL := streamResp.Videos[0]
+ if vidURL != "" && len(vidURL) > 50 {
+ filename := fmt.Sprintf("vid_%s_0.mp4", streamResp.ResponseID)
+ if streamResp.ResponseID == "" {
+ filename = fmt.Sprintf("vid_%d_0.mp4", time.Now().Unix())
+ }
+ if err := downloadAndClean(client, vidURL, filename, "video"); err == nil {
+ streamResp.Videos[0] = fmt.Sprintf("http://localhost:8000/output/%s", filename)
+ }
+ }
+ }
+
+ // Final event with full response
+ final, _ := json.Marshal(streamResp)
+ fmt.Fprintf(w, "data: %s\n\n", final)
+ fmt.Fprintf(w, "data: [DONE]\n\n")
+ w.Flush()
+ })
+ }
+
+ if len(images) > 0 {
+ // Image mode: upload + chat (auto video polling inside)
+ log.Printf("📸 Image request: %d images, prompt: %s", len(images), prompt)
+ resp, err = client.AskWithImages(prompt, images)
+ } else {
+ // Text-only mode
+ resp, err = client.Ask(prompt)
+ }
+
+ if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ userSessions.Delete(sessionID)
+ log.Printf("🔄 Session %s auto-reset due to timeout", sessionID)
+ return c.Status(504).JSON(fiber.Map{"error": "Request timed out. Session reset."})
+ }
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ // Download images locally
+ if len(resp.Images) > 0 {
+ for i, imgURL := range resp.Images {
+ filename := fmt.Sprintf("img_%s_%d.png", resp.ResponseID, i)
+ if resp.ResponseID == "" {
+ filename = fmt.Sprintf("img_%d_%d.png", time.Now().Unix(), i)
+ }
+ if err := downloadAndClean(client, imgURL, filename, "image"); err == nil {
+ resp.Images[i] = fmt.Sprintf("http://%s/output/%s", c.Host(), filename)
+ } else {
+ log.Printf("❌ Failed to download image: %v", err)
+ }
+ }
+ }
+
+ // Download videos locally
+ if len(resp.Videos) > 0 {
+ vidURL := resp.Videos[0]
+ if vidURL != "" && len(vidURL) > 50 {
+ filename := fmt.Sprintf("vid_%s_0.mp4", resp.ResponseID)
+ if resp.ResponseID == "" {
+ filename = fmt.Sprintf("vid_%d_0.mp4", time.Now().Unix())
+ }
+ if err := downloadAndClean(client, vidURL, filename, "video"); err == nil {
+ resp.Videos[0] = fmt.Sprintf("http://%s/output/%s", c.Host(), filename)
+ }
+ }
+ }
+
+ return c.JSON(resp)
+ })
+
+ // Music generation endpoint (separate because it uses tool="music_gen")
+ app.Post("/music", func(c fiber.Ctx) error {
+ var req gemini.ChatRequest
+ if err := c.Bind().JSON(&req); err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
+ }
+
+ sessionID := req.UserID
+ if sessionID == "" {
+ sessionID = c.IP()
+ }
+
+ if req.NewChat {
+ userSessions.Delete(sessionID)
+ }
+
+ client, err := getOrCreateClient(sessionID)
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ log.Printf("🎵 Music request from %s: %s", sessionID, req.Prompt)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ resp, err := client.AskWithTool(req.Prompt, "music_gen")
+ if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ userSessions.Delete(sessionID)
+ return c.Status(504).JSON(fiber.Map{"error": "Music generation timed out."})
+ }
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ if len(resp.Music) > 0 {
+ var filteredMusic []gemini.MusicTrack
+ for i, track := range resp.Music {
+ if track.DownloadURL == "" || len(track.DownloadURL) < 50 {
+ continue
+ }
+ ext := ".mp3"
+ if strings.Contains(track.DownloadURL, ".mp4") {
+ if i > 0 {
+ continue
+ }
+ ext = ".mp4"
+ }
+ filename := fmt.Sprintf("music_%s_%d%s", resp.ResponseID, i, ext)
+ if resp.ResponseID == "" {
+ filename = fmt.Sprintf("music_%d_%d%s", time.Now().Unix(), i, ext)
+ }
+ if err := downloadAndClean(client, track.DownloadURL, filename, "music"); err == nil {
+ track.LocalPath = fmt.Sprintf("http://%s/output/%s", c.Host(), filename)
+ log.Printf("🎵 Music downloaded: %s", track.Title)
+ filteredMusic = append(filteredMusic, track)
+ break
+ } else {
+ log.Printf("❌ Failed to download music track %d: %v", i, err)
+ }
+ }
+ resp.Music = filteredMusic
+ }
+
+ return c.JSON(resp)
+ })
+
+ app.Post("/reset", func(c fiber.Ctx) error {
+ type ResetRequest struct {
+ UserID string `json:"user_id"`
+ }
+ var req ResetRequest
+ c.Bind().JSON(&req)
+
+ sessionID := req.UserID
+ if sessionID == "" {
+ sessionID = c.IP()
+ }
+
+ if _, ok := userSessions.Load(sessionID); ok {
+ userSessions.Delete(sessionID)
+ return c.JSON(fiber.Map{"status": "success", "message": "Session reset"})
+ }
+
+ return c.JSON(fiber.Map{"status": "error", "message": "No active session"})
+ })
+
+ app.Get("/status", func(c fiber.Ctx) error {
+ sessionID := c.Query("user_id")
+ if sessionID == "" {
+ sessionID = c.IP()
+ }
+
+ if clientRaw, ok := userSessions.Load(sessionID); ok {
+ client := clientRaw.(*gemini.GeminiClient)
+ return c.JSON(fiber.Map{
+ "session_id": sessionID,
+ "initialized": client.IsInitialized,
+ "conversation_id": client.ConversationID,
+ "expired": false,
+ })
+ }
+ return c.JSON(fiber.Map{"session_id": sessionID, "active": false})
+ })
+
+ app.Get("/output/*", func(c fiber.Ctx) error {
+ return c.SendFile("./output/" + c.Params("*"))
+ })
+
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8001"
+ }
+ log.Fatal(app.Listen(":" + port))
+}
diff --git a/free-gemini-api/test/test.go b/free-gemini-api/test/test.go
new file mode 100644
index 0000000000000000000000000000000000000000..967cebfab2bc9f2b5f1b7e131774d08859da0d0c
--- /dev/null
+++ b/free-gemini-api/test/test.go
@@ -0,0 +1,200 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const baseURL = "http://localhost:8000"
+const userID = "test_go_client"
+
+type ChatRequest struct {
+ Prompt string `json:"prompt"`
+ UserID string `json:"user_id"`
+ NewChat bool `json:"new_chat"`
+ Stream bool `json:"stream"`
+}
+
+type GeminiResponse struct {
+ Text string `json:"text"`
+ ConversationID string `json:"conversation_id"`
+ ResponseID string `json:"response_id"`
+ ChoiceID string `json:"choice_id"`
+ Images []string `json:"images"`
+ Videos []string `json:"videos"`
+ Music interface{} `json:"music"`
+ Elapsed float64 `json:"elapsed"`
+}
+
+func main() {
+ fmt.Println("🚀 Starting API Server Endpoints Verification Tests...")
+ fmt.Printf("🔗 Target API Base URL: %s\n\n", baseURL)
+
+ // Test 1: Simple Chat Request
+ runTextTest()
+
+ // Test 2: Image Generation (Imagen 3)
+ runImageTest()
+
+ // Test 3: Music/Song Generation
+ runMusicTest()
+
+ // Test 4: Video Generation (Gemini Video)
+ runVideoTest()
+
+ fmt.Println("\n🏁 All tests finished!")
+}
+
+func runTextTest() {
+ fmt.Println("==================================================")
+ fmt.Println("📝 Test 1: Text Chat Generation")
+ fmt.Println("==================================================")
+
+ payload := ChatRequest{
+ Prompt: "Who are you? Answer in exactly one short sentence.",
+ UserID: userID,
+ NewChat: true,
+ }
+
+ resp, err := sendPost("/chat", payload)
+ if err != nil {
+ fmt.Printf("❌ Text Chat test failed: %v\n\n", err)
+ return
+ }
+
+ fmt.Printf("✅ Status: SUCCESS\n")
+ fmt.Printf("💬 Reply: %s\n", resp.Text)
+ fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed)
+}
+
+func runImageTest() {
+ fmt.Println("==================================================")
+ fmt.Println("🎨 Test 2: Image Generation (Imagen 3)")
+ fmt.Println("==================================================")
+ fmt.Println("⏳ Please wait, image generation and watermark cleaning takes a moment...")
+
+ payload := ChatRequest{
+ Prompt: "Generate a beautiful minimalist logo of a glowing purple neon butterfly.",
+ UserID: userID,
+ NewChat: false,
+ }
+
+ resp, err := sendPost("/chat", payload)
+ if err != nil {
+ fmt.Printf("❌ Image Gen test failed: %v\n\n", err)
+ return
+ }
+
+ fmt.Printf("✅ Status: SUCCESS\n")
+ if len(resp.Images) > 0 {
+ fmt.Printf("📸 Generated Image URL: %s\n", resp.Images[0])
+ } else {
+ fmt.Println("⚠️ Response text returned but no image URL found in response.")
+ fmt.Printf("💬 Response: %s\n", resp.Text)
+ }
+ fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed)
+}
+
+func runMusicTest() {
+ fmt.Println("==================================================")
+ fmt.Println("🎵 Test 3: Music/Song Generation")
+ fmt.Println("==================================================")
+ fmt.Println("⏳ Generating track...")
+
+ payload := ChatRequest{
+ Prompt: "Generate a short lofi piano beat for coding.",
+ UserID: userID,
+ NewChat: false,
+ }
+
+ resp, err := sendPost("/music", payload)
+ if err != nil {
+ fmt.Printf("❌ Music test failed: %v\n\n", err)
+ return
+ }
+
+ fmt.Printf("✅ Status: SUCCESS\n")
+ fmt.Printf("💬 Message: %s\n", resp.Text)
+
+ // Print raw music details
+ musicData, _ := json.MarshalIndent(resp.Music, "", " ")
+ fmt.Printf("🎶 Music Data: %s\n", string(musicData))
+ fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed)
+}
+
+func runVideoTest() {
+ fmt.Println("==================================================")
+ fmt.Println("🎬 Test 4: Video Generation (Gemini Video)")
+ fmt.Println("==================================================")
+ fmt.Println("💡 Note: Video generation consumes significant quota. Running test now...")
+
+ payload := ChatRequest{
+ Prompt: "Generate a 2-second cinematic video of waves gently washing onto a sandy beach.",
+ UserID: userID,
+ NewChat: false,
+ }
+
+ resp, err := sendPost("/chat", payload)
+ if err != nil {
+ fmt.Printf("❌ Video Gen test failed: %v\n\n", err)
+ return
+ }
+
+ fmt.Printf("✅ Status: SUCCESS\n")
+ if len(resp.Videos) > 0 {
+ fmt.Printf("📹 Generated Video URL: %s\n", resp.Videos[0])
+ } else {
+ fmt.Println("⚠️ No video URL returned (may have reached account daily limit/quota).")
+ fmt.Printf("💬 Response: %s\n", resp.Text)
+ }
+ fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed)
+}
+
+func sendPost(endpoint string, payload interface{}) (*GeminiResponse, error) {
+ jsonPayload, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", baseURL+endpoint, bytes.NewBuffer(jsonPayload))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{
+ Timeout: 6 * time.Minute, // High timeout for media generation
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ var result GeminiResponse
+ if err := json.Unmarshal(bodyBytes, &result); err != nil {
+ return nil, fmt.Errorf("failed to parse response JSON: %w (body: %s)", err, string(bodyBytes))
+ }
+
+ // Clean raw formatting of contribution URL in response if needed
+ if len(result.Images) > 0 {
+ result.Images[0] = strings.ReplaceAll(result.Images[0], `\`, "")
+ }
+
+ return &result, nil
+}
diff --git a/start_hf.sh b/start_hf.sh
index c687ef0331765783d1e98d78a876a48c04b9fdee..554e3211e1f6d0e1afad76909d79423c60d6f508 100644
--- a/start_hf.sh
+++ b/start_hf.sh
@@ -108,7 +108,7 @@ google-chrome-stable \
--disable-background-timer-throttling \
--disable-renderer-backgrounding \
--disable-backgrounding-occluded-windows \
- --disable-extensions \
+ --load-extension=/opt/gpt-extension,/opt/gemini-extension,/opt/flow-extension \
--disable-translate \
--memory-pressure-off \
--process-per-site \