mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
The Gemini Web API client logic has been relocated from `internal/client/gemini-web` to a new, more specific `internal/provider/gemini-web` package. This refactoring improves code organization and modularity by better isolating provider-specific implementations. As a result of this move, the `GeminiWebState` struct and its methods have been exported (capitalized) to make them accessible from the executor. All call sites have been updated to use the new package path and the exported identifiers.
395 lines
9.9 KiB
Go
395 lines
9.9 KiB
Go
package geminiwebapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// Image helpers ------------------------------------------------------------
|
|
|
|
type Image struct {
|
|
URL string
|
|
Title string
|
|
Alt string
|
|
Proxy string
|
|
}
|
|
|
|
func (i Image) String() string {
|
|
short := i.URL
|
|
if len(short) > 20 {
|
|
short = short[:8] + "..." + short[len(short)-12:]
|
|
}
|
|
return fmt.Sprintf("Image(title='%s', alt='%s', url='%s')", i.Title, i.Alt, short)
|
|
}
|
|
|
|
func (i Image) Save(path string, filename string, cookies map[string]string, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
|
if filename == "" {
|
|
// Try to parse filename from URL.
|
|
u := i.URL
|
|
if p := strings.Split(u, "/"); len(p) > 0 {
|
|
filename = p[len(p)-1]
|
|
}
|
|
if q := strings.Split(filename, "?"); len(q) > 0 {
|
|
filename = q[0]
|
|
}
|
|
}
|
|
// Regex validation (align with Python: ^(.*\.\w+)) to extract name with extension.
|
|
if filename != "" {
|
|
re := regexp.MustCompile(`^(.*\.\w+)`)
|
|
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
|
filename = m[1]
|
|
} else {
|
|
if verbose {
|
|
Warning("Invalid filename: %s", filename)
|
|
}
|
|
if skipInvalidFilename {
|
|
return "", nil
|
|
}
|
|
}
|
|
}
|
|
// Build client with cookie jar so cookies persist across redirects.
|
|
tr := &http.Transport{}
|
|
if i.Proxy != "" {
|
|
if pu, err := url.Parse(i.Proxy); err == nil {
|
|
tr.Proxy = http.ProxyURL(pu)
|
|
}
|
|
}
|
|
if insecure {
|
|
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
jar, _ := cookiejar.New(nil)
|
|
client := &http.Client{Transport: tr, Timeout: 120 * time.Second, Jar: jar}
|
|
|
|
// Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).
|
|
buildCookieHeader := func(m map[string]string) string {
|
|
if len(m) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%s", k, m[k]))
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
rawCookie := buildCookieHeader(cookies)
|
|
|
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
// Ensure provided cookies are always sent across redirects (domain-agnostic).
|
|
if rawCookie != "" {
|
|
req.Header.Set("Cookie", rawCookie)
|
|
}
|
|
if len(via) >= 10 {
|
|
return errors.New("stopped after 10 redirects")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, i.URL, nil)
|
|
if rawCookie != "" {
|
|
req.Header.Set("Cookie", rawCookie)
|
|
}
|
|
// Add browser-like headers to improve compatibility.
|
|
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
|
|
req.Header.Set("Connection", "keep-alive")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "image") {
|
|
Warning("Content type of %s is not image, but %s.", filename, ct)
|
|
}
|
|
if path == "" {
|
|
path = "temp"
|
|
}
|
|
if err = os.MkdirAll(path, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
dest := filepath.Join(path, filename)
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, err = io.Copy(f, resp.Body)
|
|
_ = f.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if verbose {
|
|
Info("Image saved as %s", dest)
|
|
}
|
|
abspath, _ := filepath.Abs(dest)
|
|
return abspath, nil
|
|
}
|
|
|
|
type WebImage struct{ Image }
|
|
|
|
type GeneratedImage struct {
|
|
Image
|
|
Cookies map[string]string
|
|
}
|
|
|
|
func (g GeneratedImage) Save(path string, filename string, fullSize bool, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
|
if len(g.Cookies) == 0 {
|
|
return "", &ValueError{Msg: "GeneratedImage requires cookies."}
|
|
}
|
|
strURL := g.URL
|
|
if fullSize {
|
|
strURL = strURL + "=s2048"
|
|
}
|
|
if filename == "" {
|
|
name := time.Now().Format("20060102150405")
|
|
if len(strURL) >= 10 {
|
|
name = fmt.Sprintf("%s_%s.png", name, strURL[len(strURL)-10:])
|
|
} else {
|
|
name += ".png"
|
|
}
|
|
filename = name
|
|
}
|
|
tmp := g.Image
|
|
tmp.URL = strURL
|
|
return tmp.Save(path, filename, g.Cookies, verbose, skipInvalidFilename, insecure)
|
|
}
|
|
|
|
// Request parsing & file helpers -------------------------------------------
|
|
|
|
func ParseMessagesAndFiles(rawJSON []byte) ([]RoleText, [][]byte, []string, [][]int, error) {
|
|
var messages []RoleText
|
|
var files [][]byte
|
|
var mimes []string
|
|
var perMsgFileIdx [][]int
|
|
|
|
contents := gjson.GetBytes(rawJSON, "contents")
|
|
if contents.Exists() {
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
role := NormalizeRole(content.Get("role").String())
|
|
var b strings.Builder
|
|
startFile := len(files)
|
|
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
|
if text := part.Get("text"); text.Exists() {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(text.String())
|
|
}
|
|
if inlineData := part.Get("inlineData"); inlineData.Exists() {
|
|
data := inlineData.Get("data").String()
|
|
if data != "" {
|
|
if dec, err := base64.StdEncoding.DecodeString(data); err == nil {
|
|
files = append(files, dec)
|
|
m := inlineData.Get("mimeType").String()
|
|
if m == "" {
|
|
m = inlineData.Get("mime_type").String()
|
|
}
|
|
mimes = append(mimes, m)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
messages = append(messages, RoleText{Role: role, Text: b.String()})
|
|
endFile := len(files)
|
|
if endFile > startFile {
|
|
idxs := make([]int, 0, endFile-startFile)
|
|
for i := startFile; i < endFile; i++ {
|
|
idxs = append(idxs, i)
|
|
}
|
|
perMsgFileIdx = append(perMsgFileIdx, idxs)
|
|
} else {
|
|
perMsgFileIdx = append(perMsgFileIdx, nil)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
return messages, files, mimes, perMsgFileIdx, nil
|
|
}
|
|
|
|
func MaterializeInlineFiles(files [][]byte, mimes []string) ([]string, *interfaces.ErrorMessage) {
|
|
if len(files) == 0 {
|
|
return nil, nil
|
|
}
|
|
paths := make([]string, 0, len(files))
|
|
for i, data := range files {
|
|
ext := MimeToExt(mimes, i)
|
|
f, err := os.CreateTemp("", "gemini-upload-*"+ext)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to create temp file: %w", err)}
|
|
}
|
|
if _, err = f.Write(data); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(f.Name())
|
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to write temp file: %w", err)}
|
|
}
|
|
if err = f.Close(); err != nil {
|
|
_ = os.Remove(f.Name())
|
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to close temp file: %w", err)}
|
|
}
|
|
paths = append(paths, f.Name())
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func CleanupFiles(paths []string) {
|
|
for _, p := range paths {
|
|
if p != "" {
|
|
_ = os.Remove(p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func FetchGeneratedImageData(gi GeneratedImage) (string, string, error) {
|
|
path, err := gi.Save("", "", true, false, true, false)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer func() { _ = os.Remove(path) }()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
mime := http.DetectContentType(b)
|
|
if !strings.HasPrefix(mime, "image/") {
|
|
if guessed := mimeFromExtension(filepath.Ext(path)); guessed != "" {
|
|
mime = guessed
|
|
} else {
|
|
mime = "image/png"
|
|
}
|
|
}
|
|
return mime, base64.StdEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func MimeToExt(mimes []string, i int) string {
|
|
if i < len(mimes) {
|
|
return MimeToPreferredExt(strings.ToLower(mimes[i]))
|
|
}
|
|
return ".png"
|
|
}
|
|
|
|
var preferredExtByMIME = map[string]string{
|
|
"image/png": ".png",
|
|
"image/jpeg": ".jpg",
|
|
"image/jpg": ".jpg",
|
|
"image/webp": ".webp",
|
|
"image/gif": ".gif",
|
|
"image/bmp": ".bmp",
|
|
"image/heic": ".heic",
|
|
"application/pdf": ".pdf",
|
|
}
|
|
|
|
func MimeToPreferredExt(mime string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(mime))
|
|
if normalized == "" {
|
|
return ".png"
|
|
}
|
|
if ext, ok := preferredExtByMIME[normalized]; ok {
|
|
return ext
|
|
}
|
|
return ".png"
|
|
}
|
|
|
|
func mimeFromExtension(ext string) string {
|
|
cleaned := strings.TrimPrefix(strings.ToLower(ext), ".")
|
|
if cleaned == "" {
|
|
return ""
|
|
}
|
|
if mt, ok := misc.MimeTypes[cleaned]; ok && mt != "" {
|
|
return mt
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// File upload helpers ------------------------------------------------------
|
|
|
|
func uploadFile(path string, proxy string, insecure bool) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = f.Close()
|
|
}()
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, err := mw.CreateFormFile("file", filepath.Base(path))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err = io.Copy(fw, f); err != nil {
|
|
return "", err
|
|
}
|
|
_ = mw.Close()
|
|
|
|
tr := &http.Transport{}
|
|
if proxy != "" {
|
|
if pu, errParse := url.Parse(proxy); errParse == nil {
|
|
tr.Proxy = http.ProxyURL(pu)
|
|
}
|
|
}
|
|
if insecure {
|
|
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
client := &http.Client{Transport: tr, Timeout: 300 * time.Second}
|
|
|
|
req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
|
|
for k, v := range HeadersUpload {
|
|
for _, vv := range v {
|
|
req.Header.Add(k, vv)
|
|
}
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("Accept", "*/*")
|
|
req.Header.Set("Connection", "keep-alive")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", &APIError{Msg: resp.Status}
|
|
}
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func parseFileName(path string) (string, error) {
|
|
if st, err := os.Stat(path); err != nil || st.IsDir() {
|
|
return "", &ValueError{Msg: path + " is not a valid file."}
|
|
}
|
|
return filepath.Base(path), nil
|
|
}
|