v6 version first commit

This commit is contained in:
Luis Pater
2025-09-22 01:40:24 +08:00
parent d42384cdb7
commit 4999fce7f4
171 changed files with 7626 additions and 7494 deletions

View File

@@ -164,7 +164,7 @@ func rotate1psidts(cookies map[string]string, proxy string, insecure bool) (stri
if st, err := os.Stat(cacheFile); err == nil {
if time.Since(st.ModTime()) <= time.Minute {
if b, err := os.ReadFile(cacheFile); err == nil {
if b, errReadFile := os.ReadFile(cacheFile); errReadFile == nil {
v := strings.TrimSpace(string(b))
if v != "" {
return v, nil
@@ -192,7 +192,9 @@ func rotate1psidts(cookies map[string]string, proxy string, insecure bool) (stri
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusUnauthorized {
return "", &AuthError{Msg: "unauthorized"}

View File

@@ -31,6 +31,13 @@ type GeminiClient struct {
rotateCancel context.CancelFunc
insecure bool
accountLabel string
// onCookiesRefreshed is an optional callback invoked after cookies
// are refreshed and the __Secure-1PSIDTS value changes.
onCookiesRefreshed func()
}
var NanoBananaModel = map[string]struct{}{
"gemini-2.5-flash-image-preview": {},
}
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
@@ -69,6 +76,13 @@ func WithAccountLabel(label string) func(*GeminiClient) {
return func(c *GeminiClient) { c.accountLabel = label }
}
// WithOnCookiesRefreshed registers a callback invoked when cookies are refreshed
// and the __Secure-1PSIDTS value changes. The callback runs in the background
// refresh goroutine; keep it lightweight and non-blocking.
func WithOnCookiesRefreshed(cb func()) func(*GeminiClient) {
return func(c *GeminiClient) { c.onCookiesRefreshed = cb }
}
// Init initializes the access token and http client.
func (c *GeminiClient) Init(timeoutSec float64, autoClose bool, closeDelaySec float64, autoRefresh bool, refreshIntervalSec float64, verbose bool) error {
// get access token
@@ -154,6 +168,10 @@ func (c *GeminiClient) startAutoRefresh() {
return
case <-ticker.C:
// Step 1: rotate __Secure-1PSIDTS
oldTS := ""
if c.Cookies != nil {
oldTS = c.Cookies["__Secure-1PSIDTS"]
}
newTS, err := rotate1psidts(c.Cookies, c.Proxy, c.insecure)
if err != nil {
Warning("Failed to refresh cookies. Background auto refresh canceled: %v", err)
@@ -186,6 +204,17 @@ func (c *GeminiClient) startAutoRefresh() {
} else {
DebugRaw("Cookies refreshed. New __Secure-1PSIDTS: %s", MaskToken28(nextCookies["__Secure-1PSIDTS"]))
}
// Trigger persistence only when TS actually changes
if c.onCookiesRefreshed != nil {
currentTS := ""
if c.Cookies != nil {
currentTS = c.Cookies["__Secure-1PSIDTS"]
}
if currentTS != "" && currentTS != oldTS {
c.onCookiesRefreshed()
}
}
}
}
}()
@@ -239,6 +268,14 @@ func (c *GeminiClient) GenerateContent(prompt string, files []string, model Mode
}
}
func ensureAnyLen(slice []any, index int) []any {
if index < len(slice) {
return slice
}
gap := index + 1 - len(slice)
return append(slice, make([]any, gap)...)
}
func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
var empty ModelOutput
// Build f.req
@@ -266,6 +303,14 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}
inner := []any{item0, nil, item2}
requestedModel := strings.ToLower(model.Name)
if chat != nil && chat.RequestedModel() != "" {
requestedModel = chat.RequestedModel()
}
if _, ok := NanoBananaModel[requestedModel]; ok {
inner = ensureAnyLen(inner, 49)
inner[49] = 14
}
if gem != nil {
// pad with 16 nils then gem ID
for i := 0; i < 16; i++ {
@@ -674,16 +719,17 @@ func truncateForLog(s string, n int) string {
// StartChat returns a ChatSession attached to the client
func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession {
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem}
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)}
}
// ChatSession holds conversation metadata
type ChatSession struct {
client *GeminiClient
metadata []string // cid, rid, rcid
lastOutput *ModelOutput
model Model
gem *Gem
client *GeminiClient
metadata []string // cid, rid, rcid
lastOutput *ModelOutput
model Model
gem *Gem
requestedModel string
}
func (cs *ChatSession) String() string {
@@ -710,6 +756,10 @@ func normalizeMeta(v []string) []string {
func (cs *ChatSession) Metadata() []string { return cs.metadata }
func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) }
func (cs *ChatSession) RequestedModel() string { return cs.requestedModel }
func (cs *ChatSession) SetRequestedModel(name string) {
cs.requestedModel = strings.ToLower(name)
}
func (cs *ChatSession) CID() string {
if len(cs.metadata) > 0 {
return cs.metadata[0]

View File

@@ -47,39 +47,6 @@ func Warning(format string, v ...any) { log.Warnf(prefix(format), v...) }
func Error(format string, v ...any) { log.Errorf(prefix(format), v...) }
func Success(format string, v ...any) { log.Infof(prefix("SUCCESS "+format), v...) }
// MaskToken hides the middle part of a sensitive value with '*'.
// It keeps up to left and right edge characters for readability.
// If input is very short, it returns a fully masked string of the same length.
func MaskToken(s string) string {
n := len(s)
if n == 0 {
return ""
}
if n <= 6 {
return strings.Repeat("*", n)
}
// Keep up to 6 chars on the left and 4 on the right, but never exceed available length
left := 6
if left > n-4 {
left = n - 4
}
right := 4
if right > n-left {
right = n - left
}
if left < 0 {
left = 0
}
if right < 0 {
right = 0
}
middle := n - left - right
if middle < 0 {
middle = 0
}
return s[:left] + strings.Repeat("*", middle) + s[n-right:]
}
// MaskToken28 returns a fixed-length (28) masked representation showing:
// first 8 chars + 8 asterisks + 4 middle chars + last 8 chars.
// If the input is shorter than 20 characters, it returns a fully masked string
@@ -90,10 +57,6 @@ func MaskToken28(s string) string {
return ""
}
if n < 20 {
// Too short to safely reveal; mask entirely but cap to 28
if n > 28 {
n = 28
}
return strings.Repeat("*", n)
}
// Pick 4 middle characters around the center
@@ -107,10 +70,10 @@ func MaskToken28(s string) string {
midStart = 8
}
}
prefix := s[:8]
prefixByte := s[:8]
middle := s[midStart : midStart+4]
suffix := s[n-8:]
return prefix + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
}
// BuildUpstreamRequestLog builds a compact preview string for upstream request logging.

View File

@@ -18,8 +18,8 @@ import (
"strings"
"time"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
misc "github.com/luispater/CLIProxyAPI/v5/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/tidwall/gjson"
)
@@ -118,7 +118,9 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Error downloading image: %d %s", resp.StatusCode, resp.Status)
}
@@ -128,7 +130,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
if path == "" {
path = "temp"
}
if err := os.MkdirAll(path, 0o755); err != nil {
if err = os.MkdirAll(path, 0o755); err != nil {
return "", err
}
dest := filepath.Join(path, filename)
@@ -159,21 +161,21 @@ func (g GeneratedImage) Save(path string, filename string, fullSize bool, verbos
if len(g.Cookies) == 0 {
return "", &ValueError{Msg: "GeneratedImage requires cookies."}
}
url := g.URL
strURL := g.URL
if fullSize {
url = url + "=s2048"
strURL = strURL + "=s2048"
}
if filename == "" {
name := time.Now().Format("20060102150405")
if len(url) >= 10 {
name = fmt.Sprintf("%s_%s.png", name, url[len(url)-10:])
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 = url
tmp.URL = strURL
return tmp.Save(path, filename, g.Cookies, verbose, skipInvalidFilename, insecure)
}
@@ -331,7 +333,9 @@ func uploadFile(path string, proxy string, insecure bool) (string, error) {
if err != nil {
return "", err
}
defer f.Close()
defer func() {
_ = f.Close()
}()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
@@ -339,14 +343,14 @@ func uploadFile(path string, proxy string, insecure bool) (string, error) {
if err != nil {
return "", err
}
if _, err := io.Copy(fw, f); err != nil {
if _, err = io.Copy(fw, f); err != nil {
return "", err
}
_ = mw.Close()
tr := &http.Transport{}
if proxy != "" {
if pu, err := url.Parse(proxy); err == nil {
if pu, errParse := url.Parse(proxy); errParse == nil {
tr.Proxy = http.ProxyURL(pu)
}
}
@@ -369,7 +373,9 @@ func uploadFile(path string, proxy string, insecure bool) (string, error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &APIError{Msg: resp.Status}
}

View File

@@ -5,7 +5,7 @@ import (
"strings"
"sync"
"github.com/luispater/CLIProxyAPI/v5/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
)
// Endpoints used by the Gemini web app

View File

@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"
bolt "go.etcd.io/bbolt"
)
// StoredMessage represents a single message in a conversation record.
@@ -76,7 +78,7 @@ func ConvStorePath(tokenFilePath string) string {
}
convDir := filepath.Join(wd, "conv")
base := strings.TrimSuffix(filepath.Base(tokenFilePath), filepath.Ext(tokenFilePath))
return filepath.Join(convDir, base+".conv.json")
return filepath.Join(convDir, base+".bolt")
}
// ConvDataPath returns the path for full conversation persistence based on token file path.
@@ -87,24 +89,41 @@ func ConvDataPath(tokenFilePath string) string {
}
convDir := filepath.Join(wd, "conv")
base := strings.TrimSuffix(filepath.Base(tokenFilePath), filepath.Ext(tokenFilePath))
return filepath.Join(convDir, base+".data.json")
return filepath.Join(convDir, base+".bolt")
}
// LoadConvStore reads the account-level metadata store from disk.
func LoadConvStore(path string) (map[string][]string, error) {
b, err := os.ReadFile(path)
if err != nil {
// Missing file is not an error; return empty map
return map[string][]string{}, nil
}
var tmp map[string][]string
if err := json.Unmarshal(b, &tmp); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if tmp == nil {
tmp = map[string][]string{}
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second})
if err != nil {
return nil, err
}
return tmp, nil
defer db.Close()
out := map[string][]string{}
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("account_meta"))
if b == nil {
return nil
}
return b.ForEach(func(k, v []byte) error {
var arr []string
if len(v) > 0 {
if e := json.Unmarshal(v, &arr); e != nil {
// Skip malformed entries instead of failing the whole load
return nil
}
}
out[string(k)] = arr
return nil
})
})
if err != nil {
return nil, err
}
return out, nil
}
// SaveConvStore writes the account-level metadata store to disk atomically.
@@ -112,19 +131,36 @@ func SaveConvStore(path string, data map[string][]string) error {
if data == nil {
data = map[string][]string{}
}
payload, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
return err
}
return os.Rename(tmp, path)
defer db.Close()
return db.Update(func(tx *bolt.Tx) error {
// Recreate bucket to reflect the given snapshot exactly.
if b := tx.Bucket([]byte("account_meta")); b != nil {
if err := tx.DeleteBucket([]byte("account_meta")); err != nil {
return err
}
}
b, err := tx.CreateBucket([]byte("account_meta"))
if err != nil {
return err
}
for k, v := range data {
enc, e := json.Marshal(v)
if e != nil {
return e
}
if e := b.Put([]byte(k), enc); e != nil {
return e
}
}
return nil
})
}
// AccountMetaKey builds the key for account-level metadata map.
@@ -134,25 +170,48 @@ func AccountMetaKey(email, modelName string) string {
// LoadConvData reads the full conversation data and index from disk.
func LoadConvData(path string) (map[string]ConversationRecord, map[string]string, error) {
b, err := os.ReadFile(path)
if err != nil {
// Missing file is not an error; return empty sets
return map[string]ConversationRecord{}, map[string]string{}, nil
}
var wrapper struct {
Items map[string]ConversationRecord `json:"items"`
Index map[string]string `json:"index"`
}
if err := json.Unmarshal(b, &wrapper); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, nil, err
}
if wrapper.Items == nil {
wrapper.Items = map[string]ConversationRecord{}
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second})
if err != nil {
return nil, nil, err
}
if wrapper.Index == nil {
wrapper.Index = map[string]string{}
defer db.Close()
items := map[string]ConversationRecord{}
index := map[string]string{}
err = db.View(func(tx *bolt.Tx) error {
// Load conv_items
if b := tx.Bucket([]byte("conv_items")); b != nil {
if e := b.ForEach(func(k, v []byte) error {
var rec ConversationRecord
if len(v) > 0 {
if e2 := json.Unmarshal(v, &rec); e2 != nil {
// Skip malformed
return nil
}
items[string(k)] = rec
}
return nil
}); e != nil {
return e
}
}
// Load conv_index
if b := tx.Bucket([]byte("conv_index")); b != nil {
if e := b.ForEach(func(k, v []byte) error {
index[string(k)] = string(v)
return nil
}); e != nil {
return e
}
}
return nil
})
if err != nil {
return nil, nil, err
}
return wrapper.Items, wrapper.Index, nil
return items, index, nil
}
// SaveConvData writes the full conversation data and index to disk atomically.
@@ -163,22 +222,52 @@ func SaveConvData(path string, items map[string]ConversationRecord, index map[st
if index == nil {
index = map[string]string{}
}
wrapper := struct {
Items map[string]ConversationRecord `json:"items"`
Index map[string]string `json:"index"`
}{Items: items, Index: index}
payload, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
return err
}
return os.Rename(tmp, path)
defer db.Close()
return db.Update(func(tx *bolt.Tx) error {
// Recreate items bucket
if b := tx.Bucket([]byte("conv_items")); b != nil {
if err := tx.DeleteBucket([]byte("conv_items")); err != nil {
return err
}
}
bi, err := tx.CreateBucket([]byte("conv_items"))
if err != nil {
return err
}
for k, rec := range items {
enc, e := json.Marshal(rec)
if e != nil {
return e
}
if e := bi.Put([]byte(k), enc); e != nil {
return e
}
}
// Recreate index bucket
if b := tx.Bucket([]byte("conv_index")); b != nil {
if err := tx.DeleteBucket([]byte("conv_index")); err != nil {
return err
}
}
bx, err := tx.CreateBucket([]byte("conv_index"))
if err != nil {
return err
}
for k, v := range index {
if e := bx.Put([]byte(k), []byte(v)); e != nil {
return e
}
}
return nil
})
}
// BuildConversationRecord constructs a ConversationRecord from history and the latest output.

View File

@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/luispater/CLIProxyAPI/v5/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
const continuationHint = "\n(More messages to come, please reply with just 'ok.')"
@@ -51,14 +51,14 @@ func SendWithSplit(chat *ChatSession, text string, files []string, cfg *config.C
return ModelOutput{}, fmt.Errorf("nil chat session")
}
// Resolve max characters per request
max := MaxCharsPerRequest(cfg)
if max <= 0 {
max = 1_000_000
// Resolve maxChars characters per request
maxChars := MaxCharsPerRequest(cfg)
if maxChars <= 0 {
maxChars = 1_000_000
}
// If within limit, send directly
if utf8.RuneCountInString(text) <= max {
if utf8.RuneCountInString(text) <= maxChars {
return chat.SendMessage(text, files)
}
@@ -73,11 +73,11 @@ func SendWithSplit(chat *ChatSession, text string, files []string, cfg *config.C
if useHint {
hintLen = utf8.RuneCountInString(continuationHint)
}
chunkSize := max - hintLen
chunkSize := maxChars - hintLen
if chunkSize <= 0 {
// max is too small to accommodate the hint; fall back to no-hint splitting
// maxChars is too small to accommodate the hint; fall back to no-hint splitting
useHint = false
chunkSize = max
chunkSize = maxChars
}
if chunkSize <= 0 {
// As a last resort, split by single rune to avoid exceeding the limit