mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Merge pull request #60 from router-for-me/v6-test
Move gemini-web to provider
This commit is contained in:
@@ -30,3 +30,4 @@ config.yaml
|
|||||||
bin/*
|
bin/*
|
||||||
.claude/*
|
.claude/*
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
.serena/*
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,8 +7,8 @@ auths/*
|
|||||||
!auths/.gitkeep
|
!auths/.gitkeep
|
||||||
.vscode/*
|
.vscode/*
|
||||||
.claude/*
|
.claude/*
|
||||||
|
.serena/*
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
*.exe
|
*.exe
|
||||||
temp/*
|
temp/*
|
||||||
.serena/
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package geminiwebapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// init honors GEMINI_WEBAPI_LOG to keep parity with the Python client.
|
|
||||||
func init() {
|
|
||||||
if lvl := os.Getenv("GEMINI_WEBAPI_LOG"); lvl != "" {
|
|
||||||
SetLogLevel(lvl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLogLevel adjusts logging verbosity using CLI-style strings.
|
|
||||||
func SetLogLevel(level string) {
|
|
||||||
switch strings.ToUpper(level) {
|
|
||||||
case "TRACE":
|
|
||||||
log.SetLevel(log.TraceLevel)
|
|
||||||
case "DEBUG":
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
case "INFO":
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
case "WARNING", "WARN":
|
|
||||||
log.SetLevel(log.WarnLevel)
|
|
||||||
case "ERROR":
|
|
||||||
log.SetLevel(log.ErrorLevel)
|
|
||||||
case "CRITICAL", "FATAL":
|
|
||||||
log.SetLevel(log.FatalLevel)
|
|
||||||
default:
|
|
||||||
log.SetLevel(log.InfoLevel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func prefix(format string) string { return "[gemini_webapi] " + format }
|
|
||||||
|
|
||||||
func Debug(format string, v ...any) { log.Debugf(prefix(format), v...) }
|
|
||||||
|
|
||||||
// DebugRaw logs without the module prefix; use sparingly for messages
|
|
||||||
// that should integrate with global formatting without extra tags.
|
|
||||||
func DebugRaw(format string, v ...any) { log.Debugf(format, v...) }
|
|
||||||
func Info(format string, v ...any) { log.Infof(prefix(format), v...) }
|
|
||||||
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...) }
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// of length min(len(s), 28).
|
|
||||||
func MaskToken28(s string) string {
|
|
||||||
n := len(s)
|
|
||||||
if n == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if n < 20 {
|
|
||||||
return strings.Repeat("*", n)
|
|
||||||
}
|
|
||||||
// Pick 4 middle characters around the center
|
|
||||||
midStart := n/2 - 2
|
|
||||||
if midStart < 8 {
|
|
||||||
midStart = 8
|
|
||||||
}
|
|
||||||
if midStart+4 > n-8 {
|
|
||||||
midStart = n - 8 - 4
|
|
||||||
if midStart < 8 {
|
|
||||||
midStart = 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefixByte := s[:8]
|
|
||||||
middle := s[midStart : midStart+4]
|
|
||||||
suffix := s[n-8:]
|
|
||||||
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildUpstreamRequestLog builds a compact preview string for upstream request logging.
|
|
||||||
func BuildUpstreamRequestLog(account string, contextOn bool, useTags, explicitContext bool, prompt string, filesCount int, reuse bool, metaLen int, gem *Gem) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("\n\n=== GEMINI WEB UPSTREAM ===\n")
|
|
||||||
sb.WriteString(fmt.Sprintf("account: %s\n", account))
|
|
||||||
if contextOn {
|
|
||||||
sb.WriteString("context_mode: on\n")
|
|
||||||
} else {
|
|
||||||
sb.WriteString("context_mode: off\n")
|
|
||||||
}
|
|
||||||
if reuse {
|
|
||||||
sb.WriteString("reuseIdx: 1\n")
|
|
||||||
} else {
|
|
||||||
sb.WriteString("reuseIdx: 0\n")
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("useTags: %t\n", useTags))
|
|
||||||
sb.WriteString(fmt.Sprintf("metadata_len: %d\n", metaLen))
|
|
||||||
if explicitContext {
|
|
||||||
sb.WriteString("explicit_context: true\n")
|
|
||||||
} else {
|
|
||||||
sb.WriteString("explicit_context: false\n")
|
|
||||||
}
|
|
||||||
if filesCount > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("files: %d\n", filesCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
if gem != nil {
|
|
||||||
sb.WriteString("gem:\n")
|
|
||||||
if gem.ID != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" id: %s\n", gem.ID))
|
|
||||||
}
|
|
||||||
if gem.Name != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" name: %s\n", gem.Name))
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf(" predefined: %t\n", gem.Predefined))
|
|
||||||
} else {
|
|
||||||
sb.WriteString("gem: none\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks := ChunkByRunes(prompt, 4096)
|
|
||||||
preview := prompt
|
|
||||||
truncated := false
|
|
||||||
if len(chunks) > 1 {
|
|
||||||
preview = chunks[0]
|
|
||||||
truncated = true
|
|
||||||
}
|
|
||||||
sb.WriteString("prompt_preview:\n")
|
|
||||||
sb.WriteString(preview)
|
|
||||||
if truncated {
|
|
||||||
sb.WriteString("\n... [truncated]\n")
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type httpOptions struct {
|
type httpOptions struct {
|
||||||
@@ -103,7 +105,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
|
|||||||
}
|
}
|
||||||
trySets = append(trySets, merged)
|
trySets = append(trySets, merged)
|
||||||
} else if verbose {
|
} else if verbose {
|
||||||
Debug("Skipping base cookies: __Secure-1PSIDTS missing")
|
log.Debug("Skipping base cookies: __Secure-1PSIDTS missing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
|
|||||||
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
|
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if verbose {
|
if verbose {
|
||||||
Warning("Failed init request: %v", err)
|
log.Warnf("Failed init request: %v", err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
|
|||||||
if len(matches) >= 2 {
|
if len(matches) >= 2 {
|
||||||
token := matches[1]
|
token := matches[1]
|
||||||
if verbose {
|
if verbose {
|
||||||
Success("Gemini access token acquired.")
|
log.Infof("Gemini access token acquired.")
|
||||||
}
|
}
|
||||||
return token, mergedCookies, nil
|
return token, mergedCookies, nil
|
||||||
}
|
}
|
||||||
@@ -212,3 +214,27 @@ func (r *constReader) Read(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stringsReader(s string) io.Reader { return &constReader{s: s} }
|
func stringsReader(s string) io.Reader { return &constReader{s: s} }
|
||||||
|
|
||||||
|
func MaskToken28(s string) string {
|
||||||
|
n := len(s)
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if n < 20 {
|
||||||
|
return strings.Repeat("*", n)
|
||||||
|
}
|
||||||
|
midStart := n/2 - 2
|
||||||
|
if midStart < 8 {
|
||||||
|
midStart = 8
|
||||||
|
}
|
||||||
|
if midStart+4 > n-8 {
|
||||||
|
midStart = n - 8 - 4
|
||||||
|
if midStart < 8 {
|
||||||
|
midStart = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefixByte := s[:8]
|
||||||
|
middle := s[midStart : midStart+4]
|
||||||
|
suffix := s[n-8:]
|
||||||
|
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeminiClient is the async http client interface (Go port)
|
// GeminiClient is the async http client interface (Go port)
|
||||||
@@ -79,7 +81,7 @@ func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error {
|
|||||||
|
|
||||||
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
|
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
|
||||||
if verbose {
|
if verbose {
|
||||||
Success("Gemini client initialized successfully.")
|
log.Infof("Gemini client initialized successfully.")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
|||||||
filename = m[1]
|
filename = m[1]
|
||||||
} else {
|
} else {
|
||||||
if verbose {
|
if verbose {
|
||||||
Warning("Invalid filename: %s", filename)
|
log.Warnf("Invalid filename: %s", filename)
|
||||||
}
|
}
|
||||||
if skipInvalidFilename {
|
if skipInvalidFilename {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -125,7 +126,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
|||||||
return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status)
|
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") {
|
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)
|
log.Warnf("Content type of %s is not image, but %s.", filename, ct)
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = "temp"
|
path = "temp"
|
||||||
@@ -144,7 +145,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if verbose {
|
if verbose {
|
||||||
Info("Image saved as %s", dest)
|
log.Infof("Image saved as %s", dest)
|
||||||
}
|
}
|
||||||
abspath, _ := filepath.Abs(dest)
|
abspath, _ := filepath.Abs(dest)
|
||||||
return abspath, nil
|
return abspath, nil
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package executor
|
package geminiwebapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||||
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/client/gemini-web"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
geminiWebDefaultTimeoutSec = 300
|
geminiWebDefaultTimeoutSec = 300
|
||||||
)
|
)
|
||||||
|
|
||||||
type geminiWebState struct {
|
type GeminiWebState struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
token *gemini.GeminiWebTokenStorage
|
token *gemini.GeminiWebTokenStorage
|
||||||
storagePath string
|
storagePath string
|
||||||
@@ -34,29 +34,29 @@ type geminiWebState struct {
|
|||||||
accountID string
|
accountID string
|
||||||
|
|
||||||
reqMu sync.Mutex
|
reqMu sync.Mutex
|
||||||
client *geminiwebapi.GeminiClient
|
client *GeminiClient
|
||||||
|
|
||||||
tokenMu sync.Mutex
|
tokenMu sync.Mutex
|
||||||
tokenDirty bool
|
tokenDirty bool
|
||||||
|
|
||||||
convMu sync.RWMutex
|
convMu sync.RWMutex
|
||||||
convStore map[string][]string
|
convStore map[string][]string
|
||||||
convData map[string]geminiwebapi.ConversationRecord
|
convData map[string]ConversationRecord
|
||||||
convIndex map[string]string
|
convIndex map[string]string
|
||||||
|
|
||||||
lastRefresh time.Time
|
lastRefresh time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *geminiWebState {
|
func NewGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *GeminiWebState {
|
||||||
state := &geminiWebState{
|
state := &GeminiWebState{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
token: token,
|
token: token,
|
||||||
storagePath: storagePath,
|
storagePath: storagePath,
|
||||||
convStore: make(map[string][]string),
|
convStore: make(map[string][]string),
|
||||||
convData: make(map[string]geminiwebapi.ConversationRecord),
|
convData: make(map[string]ConversationRecord),
|
||||||
convIndex: make(map[string]string),
|
convIndex: make(map[string]string),
|
||||||
}
|
}
|
||||||
suffix := geminiwebapi.Sha256Hex(token.Secure1PSID)
|
suffix := Sha256Hex(token.Secure1PSID)
|
||||||
if len(suffix) > 16 {
|
if len(suffix) > 16 {
|
||||||
suffix = suffix[:16]
|
suffix = suffix[:16]
|
||||||
}
|
}
|
||||||
@@ -75,39 +75,39 @@ func newGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage,
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) loadConversationCaches() {
|
func (s *GeminiWebState) loadConversationCaches() {
|
||||||
if path := s.convStorePath(); path != "" {
|
if path := s.convStorePath(); path != "" {
|
||||||
if store, err := geminiwebapi.LoadConvStore(path); err == nil {
|
if store, err := LoadConvStore(path); err == nil {
|
||||||
s.convStore = store
|
s.convStore = store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if path := s.convDataPath(); path != "" {
|
if path := s.convDataPath(); path != "" {
|
||||||
if items, index, err := geminiwebapi.LoadConvData(path); err == nil {
|
if items, index, err := LoadConvData(path); err == nil {
|
||||||
s.convData = items
|
s.convData = items
|
||||||
s.convIndex = index
|
s.convIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) convStorePath() string {
|
func (s *GeminiWebState) convStorePath() string {
|
||||||
base := s.storagePath
|
base := s.storagePath
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = s.accountID + ".json"
|
base = s.accountID + ".json"
|
||||||
}
|
}
|
||||||
return geminiwebapi.ConvStorePath(base)
|
return ConvStorePath(base)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) convDataPath() string {
|
func (s *GeminiWebState) convDataPath() string {
|
||||||
base := s.storagePath
|
base := s.storagePath
|
||||||
if base == "" {
|
if base == "" {
|
||||||
base = s.accountID + ".json"
|
base = s.accountID + ".json"
|
||||||
}
|
}
|
||||||
return geminiwebapi.ConvDataPath(base)
|
return ConvDataPath(base)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) getRequestMutex() *sync.Mutex { return &s.reqMu }
|
func (s *GeminiWebState) GetRequestMutex() *sync.Mutex { return &s.reqMu }
|
||||||
|
|
||||||
func (s *geminiWebState) ensureClient() error {
|
func (s *GeminiWebState) EnsureClient() error {
|
||||||
if s.client != nil && s.client.Running {
|
if s.client != nil && s.client.Running {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func (s *geminiWebState) ensureClient() error {
|
|||||||
if s.cfg != nil {
|
if s.cfg != nil {
|
||||||
proxyURL = s.cfg.ProxyURL
|
proxyURL = s.cfg.ProxyURL
|
||||||
}
|
}
|
||||||
s.client = geminiwebapi.NewGeminiClient(
|
s.client = NewGeminiClient(
|
||||||
s.token.Secure1PSID,
|
s.token.Secure1PSID,
|
||||||
s.token.Secure1PSIDTS,
|
s.token.Secure1PSIDTS,
|
||||||
proxyURL,
|
proxyURL,
|
||||||
@@ -129,13 +129,13 @@ func (s *geminiWebState) ensureClient() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) refresh(ctx context.Context) error {
|
func (s *GeminiWebState) Refresh(ctx context.Context) error {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
if s.cfg != nil {
|
if s.cfg != nil {
|
||||||
proxyURL = s.cfg.ProxyURL
|
proxyURL = s.cfg.ProxyURL
|
||||||
}
|
}
|
||||||
s.client = geminiwebapi.NewGeminiClient(
|
s.client = NewGeminiClient(
|
||||||
s.token.Secure1PSID,
|
s.token.Secure1PSID,
|
||||||
s.token.Secure1PSIDTS,
|
s.token.Secure1PSIDTS,
|
||||||
proxyURL,
|
proxyURL,
|
||||||
@@ -158,7 +158,7 @@ func (s *geminiWebState) refresh(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) tokenSnapshot() *gemini.GeminiWebTokenStorage {
|
func (s *GeminiWebState) TokenSnapshot() *gemini.GeminiWebTokenStorage {
|
||||||
s.tokenMu.Lock()
|
s.tokenMu.Lock()
|
||||||
defer s.tokenMu.Unlock()
|
defer s.tokenMu.Unlock()
|
||||||
c := *s.token
|
c := *s.token
|
||||||
@@ -170,15 +170,15 @@ type geminiWebPrepared struct {
|
|||||||
translatedRaw []byte
|
translatedRaw []byte
|
||||||
prompt string
|
prompt string
|
||||||
uploaded []string
|
uploaded []string
|
||||||
chat *geminiwebapi.ChatSession
|
chat *ChatSession
|
||||||
cleaned []geminiwebapi.RoleText
|
cleaned []RoleText
|
||||||
underlying string
|
underlying string
|
||||||
reuse bool
|
reuse bool
|
||||||
tagged bool
|
tagged bool
|
||||||
originalRaw []byte
|
originalRaw []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON []byte, stream bool, original []byte) (*geminiWebPrepared, *interfaces.ErrorMessage) {
|
func (s *GeminiWebState) prepare(ctx context.Context, modelName string, rawJSON []byte, stream bool, original []byte) (*geminiWebPrepared, *interfaces.ErrorMessage) {
|
||||||
res := &geminiWebPrepared{originalRaw: original}
|
res := &geminiWebPrepared{originalRaw: original}
|
||||||
res.translatedRaw = bytes.Clone(rawJSON)
|
res.translatedRaw = bytes.Clone(rawJSON)
|
||||||
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok && handler != nil {
|
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok && handler != nil {
|
||||||
@@ -187,14 +187,14 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
}
|
}
|
||||||
recordAPIRequest(ctx, s.cfg, res.translatedRaw)
|
recordAPIRequest(ctx, s.cfg, res.translatedRaw)
|
||||||
|
|
||||||
messages, files, mimes, msgFileIdx, err := geminiwebapi.ParseMessagesAndFiles(res.translatedRaw)
|
messages, files, mimes, msgFileIdx, err := ParseMessagesAndFiles(res.translatedRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
|
||||||
}
|
}
|
||||||
cleaned := geminiwebapi.SanitizeAssistantMessages(messages)
|
cleaned := SanitizeAssistantMessages(messages)
|
||||||
res.cleaned = cleaned
|
res.cleaned = cleaned
|
||||||
res.underlying = geminiwebapi.MapAliasToUnderlying(modelName)
|
res.underlying = MapAliasToUnderlying(modelName)
|
||||||
model, err := geminiwebapi.ModelFromName(res.underlying)
|
model, err := ModelFromName(res.underlying)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
|
||||||
}
|
}
|
||||||
@@ -210,11 +210,11 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
res.reuse = true
|
res.reuse = true
|
||||||
meta = reuseMeta
|
meta = reuseMeta
|
||||||
if len(remaining) == 1 {
|
if len(remaining) == 1 {
|
||||||
useMsgs = []geminiwebapi.RoleText{remaining[0]}
|
useMsgs = []RoleText{remaining[0]}
|
||||||
} else if len(remaining) > 1 {
|
} else if len(remaining) > 1 {
|
||||||
useMsgs = remaining
|
useMsgs = remaining
|
||||||
} else if len(cleaned) > 0 {
|
} else if len(cleaned) > 0 {
|
||||||
useMsgs = []geminiwebapi.RoleText{cleaned[len(cleaned)-1]}
|
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
|
||||||
}
|
}
|
||||||
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
|
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
|
||||||
lastIdx := len(msgFileIdx) - 1
|
lastIdx := len(msgFileIdx) - 1
|
||||||
@@ -242,8 +242,8 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
|
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
|
||||||
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, res.underlying)
|
keyUnderlying := AccountMetaKey(s.accountID, res.underlying)
|
||||||
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
|
keyAlias := AccountMetaKey(s.accountID, modelName)
|
||||||
s.convMu.RLock()
|
s.convMu.RLock()
|
||||||
fallbackMeta := s.convStore[keyUnderlying]
|
fallbackMeta := s.convStore[keyUnderlying]
|
||||||
if len(fallbackMeta) == 0 {
|
if len(fallbackMeta) == 0 {
|
||||||
@@ -252,7 +252,7 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
s.convMu.RUnlock()
|
s.convMu.RUnlock()
|
||||||
if len(fallbackMeta) > 0 {
|
if len(fallbackMeta) > 0 {
|
||||||
meta = fallbackMeta
|
meta = fallbackMeta
|
||||||
useMsgs = []geminiwebapi.RoleText{cleaned[len(cleaned)-1]}
|
useMsgs = []RoleText{cleaned[len(cleaned)-1]}
|
||||||
res.reuse = true
|
res.reuse = true
|
||||||
filesSubset = nil
|
filesSubset = nil
|
||||||
mimesSubset = nil
|
mimesSubset = nil
|
||||||
@@ -260,8 +260,8 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, res.underlying)
|
keyUnderlying := AccountMetaKey(s.accountID, res.underlying)
|
||||||
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
|
keyAlias := AccountMetaKey(s.accountID, modelName)
|
||||||
s.convMu.RLock()
|
s.convMu.RLock()
|
||||||
if v, ok := s.convStore[keyUnderlying]; ok && len(v) > 0 {
|
if v, ok := s.convStore[keyUnderlying]; ok && len(v) > 0 {
|
||||||
meta = v
|
meta = v
|
||||||
@@ -271,26 +271,26 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
s.convMu.RUnlock()
|
s.convMu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
res.tagged = geminiwebapi.NeedRoleTags(useMsgs)
|
res.tagged = NeedRoleTags(useMsgs)
|
||||||
if res.reuse && len(useMsgs) == 1 {
|
if res.reuse && len(useMsgs) == 1 {
|
||||||
res.tagged = false
|
res.tagged = false
|
||||||
}
|
}
|
||||||
|
|
||||||
enableXML := s.cfg != nil && s.cfg.GeminiWeb.CodeMode
|
enableXML := s.cfg != nil && s.cfg.GeminiWeb.CodeMode
|
||||||
useMsgs = geminiwebapi.AppendXMLWrapHintIfNeeded(useMsgs, !enableXML)
|
useMsgs = AppendXMLWrapHintIfNeeded(useMsgs, !enableXML)
|
||||||
|
|
||||||
res.prompt = geminiwebapi.BuildPrompt(useMsgs, res.tagged, res.tagged)
|
res.prompt = BuildPrompt(useMsgs, res.tagged, res.tagged)
|
||||||
if strings.TrimSpace(res.prompt) == "" {
|
if strings.TrimSpace(res.prompt) == "" {
|
||||||
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: errors.New("bad request: empty prompt after filtering system/thought content")}
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: errors.New("bad request: empty prompt after filtering system/thought content")}
|
||||||
}
|
}
|
||||||
|
|
||||||
uploaded, upErr := geminiwebapi.MaterializeInlineFiles(filesSubset, mimesSubset)
|
uploaded, upErr := MaterializeInlineFiles(filesSubset, mimesSubset)
|
||||||
if upErr != nil {
|
if upErr != nil {
|
||||||
return nil, upErr
|
return nil, upErr
|
||||||
}
|
}
|
||||||
res.uploaded = uploaded
|
res.uploaded = uploaded
|
||||||
|
|
||||||
if err = s.ensureClient(); err != nil {
|
if err = s.EnsureClient(); err != nil {
|
||||||
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
|
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
|
||||||
}
|
}
|
||||||
chat := s.client.StartChat(model, s.getConfiguredGem(), meta)
|
chat := s.client.StartChat(model, s.getConfiguredGem(), meta)
|
||||||
@@ -300,14 +300,14 @@ func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload []byte, opts cliproxyexecutor.Options) ([]byte, *interfaces.ErrorMessage, *geminiWebPrepared) {
|
func (s *GeminiWebState) Send(ctx context.Context, modelName string, reqPayload []byte, opts cliproxyexecutor.Options) ([]byte, *interfaces.ErrorMessage, *geminiWebPrepared) {
|
||||||
prep, errMsg := s.prepare(ctx, modelName, reqPayload, opts.Stream, opts.OriginalRequest)
|
prep, errMsg := s.prepare(ctx, modelName, reqPayload, opts.Stream, opts.OriginalRequest)
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
return nil, errMsg, nil
|
return nil, errMsg, nil
|
||||||
}
|
}
|
||||||
defer geminiwebapi.CleanupFiles(prep.uploaded)
|
defer CleanupFiles(prep.uploaded)
|
||||||
|
|
||||||
output, err := geminiwebapi.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg)
|
output, err := SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, s.wrapSendError(err), nil
|
return nil, s.wrapSendError(err), nil
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gemBytes, err := geminiwebapi.ConvertOutputToGemini(&output, modelName, prep.prompt)
|
gemBytes, err := ConvertOutputToGemini(&output, modelName, prep.prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}, nil
|
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}, nil
|
||||||
}
|
}
|
||||||
@@ -341,13 +341,13 @@ func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload
|
|||||||
return gemBytes, nil, prep
|
return gemBytes, nil, prep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
|
func (s *GeminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
|
||||||
status := 500
|
status := 500
|
||||||
var usage *geminiwebapi.UsageLimitExceeded
|
var usage *UsageLimitExceeded
|
||||||
var blocked *geminiwebapi.TemporarilyBlocked
|
var blocked *TemporarilyBlocked
|
||||||
var invalid *geminiwebapi.ModelInvalid
|
var invalid *ModelInvalid
|
||||||
var valueErr *geminiwebapi.ValueError
|
var valueErr *ValueError
|
||||||
var timeout *geminiwebapi.TimeoutError
|
var timeout *TimeoutError
|
||||||
switch {
|
switch {
|
||||||
case errors.As(genErr, &usage):
|
case errors.As(genErr, &usage):
|
||||||
status = 429
|
status = 429
|
||||||
@@ -363,14 +363,14 @@ func (s *geminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
|
|||||||
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
|
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPrepared, output *geminiwebapi.ModelOutput) {
|
func (s *GeminiWebState) persistConversation(modelName string, prep *geminiWebPrepared, output *ModelOutput) {
|
||||||
if output == nil || prep == nil || prep.chat == nil {
|
if output == nil || prep == nil || prep.chat == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
metadata := prep.chat.Metadata()
|
metadata := prep.chat.Metadata()
|
||||||
if len(metadata) > 0 {
|
if len(metadata) > 0 {
|
||||||
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, prep.underlying)
|
keyUnderlying := AccountMetaKey(s.accountID, prep.underlying)
|
||||||
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
|
keyAlias := AccountMetaKey(s.accountID, modelName)
|
||||||
s.convMu.Lock()
|
s.convMu.Lock()
|
||||||
s.convStore[keyUnderlying] = metadata
|
s.convStore[keyUnderlying] = metadata
|
||||||
s.convStore[keyAlias] = metadata
|
s.convStore[keyAlias] = metadata
|
||||||
@@ -384,18 +384,18 @@ func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPr
|
|||||||
storeSnapshot[k] = cp
|
storeSnapshot[k] = cp
|
||||||
}
|
}
|
||||||
s.convMu.Unlock()
|
s.convMu.Unlock()
|
||||||
_ = geminiwebapi.SaveConvStore(s.convStorePath(), storeSnapshot)
|
_ = SaveConvStore(s.convStorePath(), storeSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.useReusableContext() {
|
if !s.useReusableContext() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec, ok := geminiwebapi.BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata)
|
rec, ok := BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stableHash := geminiwebapi.HashConversation(rec.ClientID, prep.underlying, rec.Messages)
|
stableHash := HashConversation(rec.ClientID, prep.underlying, rec.Messages)
|
||||||
accountHash := geminiwebapi.HashConversation(s.accountID, prep.underlying, rec.Messages)
|
accountHash := HashConversation(s.accountID, prep.underlying, rec.Messages)
|
||||||
|
|
||||||
s.convMu.Lock()
|
s.convMu.Lock()
|
||||||
s.convData[stableHash] = rec
|
s.convData[stableHash] = rec
|
||||||
@@ -403,7 +403,7 @@ func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPr
|
|||||||
if accountHash != stableHash {
|
if accountHash != stableHash {
|
||||||
s.convIndex["hash:"+accountHash] = stableHash
|
s.convIndex["hash:"+accountHash] = stableHash
|
||||||
}
|
}
|
||||||
dataSnapshot := make(map[string]geminiwebapi.ConversationRecord, len(s.convData))
|
dataSnapshot := make(map[string]ConversationRecord, len(s.convData))
|
||||||
for k, v := range s.convData {
|
for k, v := range s.convData {
|
||||||
dataSnapshot[k] = v
|
dataSnapshot[k] = v
|
||||||
}
|
}
|
||||||
@@ -412,14 +412,14 @@ func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPr
|
|||||||
indexSnapshot[k] = v
|
indexSnapshot[k] = v
|
||||||
}
|
}
|
||||||
s.convMu.Unlock()
|
s.convMu.Unlock()
|
||||||
_ = geminiwebapi.SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)
|
_ = SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
|
func (s *GeminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
|
||||||
appendAPIResponseChunk(ctx, s.cfg, line)
|
appendAPIResponseChunk(ctx, s.cfg, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) convertToTarget(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []byte {
|
func (s *GeminiWebState) ConvertToTarget(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []byte {
|
||||||
if prep == nil || prep.handlerType == "" {
|
if prep == nil || prep.handlerType == "" {
|
||||||
return gemBytes
|
return gemBytes
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ func (s *geminiWebState) convertToTarget(ctx context.Context, modelName string,
|
|||||||
return []byte(out)
|
return []byte(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) convertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string {
|
func (s *GeminiWebState) ConvertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string {
|
||||||
if prep == nil || prep.handlerType == "" {
|
if prep == nil || prep.handlerType == "" {
|
||||||
return []string{string(gemBytes)}
|
return []string{string(gemBytes)}
|
||||||
}
|
}
|
||||||
@@ -448,7 +448,7 @@ func (s *geminiWebState) convertStream(ctx context.Context, modelName string, pr
|
|||||||
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, ¶m)
|
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, ¶m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) doneStream(ctx context.Context, modelName string, prep *geminiWebPrepared) []string {
|
func (s *GeminiWebState) DoneStream(ctx context.Context, modelName string, prep *geminiWebPrepared) []string {
|
||||||
if prep == nil || prep.handlerType == "" {
|
if prep == nil || prep.handlerType == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -459,24 +459,56 @@ func (s *geminiWebState) doneStream(ctx context.Context, modelName string, prep
|
|||||||
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
return translator.Response(prep.handlerType, constant.GeminiWeb, ctx, modelName, prep.originalRaw, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) useReusableContext() bool {
|
func (s *GeminiWebState) useReusableContext() bool {
|
||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.cfg.GeminiWeb.Context
|
return s.cfg.GeminiWeb.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) findReusableSession(modelName string, msgs []geminiwebapi.RoleText) ([]string, []geminiwebapi.RoleText) {
|
func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText) ([]string, []RoleText) {
|
||||||
s.convMu.RLock()
|
s.convMu.RLock()
|
||||||
items := s.convData
|
items := s.convData
|
||||||
index := s.convIndex
|
index := s.convIndex
|
||||||
s.convMu.RUnlock()
|
s.convMu.RUnlock()
|
||||||
return geminiwebapi.FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
|
return FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *geminiWebState) getConfiguredGem() *geminiwebapi.Gem {
|
func (s *GeminiWebState) getConfiguredGem() *Gem {
|
||||||
if s.cfg != nil && s.cfg.GeminiWeb.CodeMode {
|
if s.cfg != nil && s.cfg.GeminiWeb.CodeMode {
|
||||||
return &geminiwebapi.Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
|
return &Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordAPIRequest stores the upstream request payload in Gin context for request logging.
|
||||||
|
func recordAPIRequest(ctx context.Context, cfg *config.Config, payload []byte) {
|
||||||
|
if cfg == nil || !cfg.RequestLog || len(payload) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
|
||||||
|
ginCtx.Set("API_REQUEST", bytes.Clone(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendAPIResponseChunk appends an upstream response chunk to Gin context for request logging.
|
||||||
|
func appendAPIResponseChunk(ctx context.Context, cfg *config.Config, chunk []byte) {
|
||||||
|
if cfg == nil || !cfg.RequestLog {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := bytes.TrimSpace(bytes.Clone(chunk))
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
|
||||||
|
if existing, exists := ginCtx.Get("API_RESPONSE"); exists {
|
||||||
|
if prev, okBytes := existing.([]byte); okBytes {
|
||||||
|
prev = append(prev, data...)
|
||||||
|
prev = append(prev, []byte("\n\n")...)
|
||||||
|
ginCtx.Set("API_RESPONSE", prev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ginCtx.Set("API_RESPONSE", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
|
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
@@ -35,23 +36,23 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return cliproxyexecutor.Response{}, err
|
return cliproxyexecutor.Response{}, err
|
||||||
}
|
}
|
||||||
if err = state.ensureClient(); err != nil {
|
if err = state.EnsureClient(); err != nil {
|
||||||
return cliproxyexecutor.Response{}, err
|
return cliproxyexecutor.Response{}, err
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||||
|
|
||||||
mutex := state.getRequestMutex()
|
mutex := state.GetRequestMutex()
|
||||||
if mutex != nil {
|
if mutex != nil {
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
defer mutex.Unlock()
|
defer mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := bytes.Clone(req.Payload)
|
payload := bytes.Clone(req.Payload)
|
||||||
resp, errMsg, prep := state.send(ctx, req.Model, payload, opts)
|
resp, errMsg, prep := state.Send(ctx, req.Model, payload, opts)
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
|
return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
|
||||||
}
|
}
|
||||||
resp = state.convertToTarget(ctx, req.Model, prep, resp)
|
resp = state.ConvertToTarget(ctx, req.Model, prep, resp)
|
||||||
reporter.publish(ctx, parseGeminiUsage(resp))
|
reporter.publish(ctx, parseGeminiUsage(resp))
|
||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
@@ -67,17 +68,17 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = state.ensureClient(); err != nil {
|
if err = state.EnsureClient(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||||
|
|
||||||
mutex := state.getRequestMutex()
|
mutex := state.GetRequestMutex()
|
||||||
if mutex != nil {
|
if mutex != nil {
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
gemBytes, errMsg, prep := state.send(ctx, req.Model, bytes.Clone(req.Payload), opts)
|
gemBytes, errMsg, prep := state.Send(ctx, req.Model, bytes.Clone(req.Payload), opts)
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
if mutex != nil {
|
if mutex != nil {
|
||||||
mutex.Unlock()
|
mutex.Unlock()
|
||||||
@@ -90,8 +91,8 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
to := sdktranslator.FromString("gemini-web")
|
to := sdktranslator.FromString("gemini-web")
|
||||||
var param any
|
var param any
|
||||||
|
|
||||||
lines := state.convertStream(ctx, req.Model, prep, gemBytes)
|
lines := state.ConvertStream(ctx, req.Model, prep, gemBytes)
|
||||||
done := state.doneStream(ctx, req.Model, prep)
|
done := state.DoneStream(ctx, req.Model, prep)
|
||||||
out := make(chan cliproxyexecutor.StreamChunk)
|
out := make(chan cliproxyexecutor.StreamChunk)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(out)
|
defer close(out)
|
||||||
@@ -124,10 +125,10 @@ func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err = state.refresh(ctx); err != nil {
|
if err = state.Refresh(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ts := state.tokenSnapshot()
|
ts := state.TokenSnapshot()
|
||||||
if auth.Metadata == nil {
|
if auth.Metadata == nil {
|
||||||
auth.Metadata = make(map[string]any)
|
auth.Metadata = make(map[string]any)
|
||||||
}
|
}
|
||||||
@@ -139,10 +140,10 @@ func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
type geminiWebRuntime struct {
|
type geminiWebRuntime struct {
|
||||||
state *geminiWebState
|
state *geminiwebapi.GeminiWebState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiWebState, error) {
|
func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.GeminiWebState, error) {
|
||||||
if auth == nil {
|
if auth == nil {
|
||||||
return nil, fmt.Errorf("gemini-web executor: auth is nil")
|
return nil, fmt.Errorf("gemini-web executor: auth is nil")
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiWebState,
|
|||||||
storagePath = p
|
storagePath = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state := newGeminiWebState(cfg, ts, storagePath)
|
state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath)
|
||||||
runtime := &geminiWebRuntime{state: state}
|
runtime := &geminiWebRuntime{state: state}
|
||||||
auth.Runtime = runtime
|
auth.Runtime = runtime
|
||||||
return state, nil
|
return state, nil
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const cookieSnapshotExt = ".cookie"
|
|
||||||
|
|
||||||
// CookieSnapshotPath derives the cookie snapshot file path from the main token JSON path.
|
|
||||||
// It replaces the .json suffix with .cookie, or appends .cookie if missing.
|
|
||||||
func CookieSnapshotPath(mainPath string) string {
|
|
||||||
if strings.HasSuffix(mainPath, ".json") {
|
|
||||||
return strings.TrimSuffix(mainPath, ".json") + cookieSnapshotExt
|
|
||||||
}
|
|
||||||
return mainPath + cookieSnapshotExt
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRegularFile reports whether the given path exists and is a regular file.
|
|
||||||
func IsRegularFile(path string) bool {
|
|
||||||
if path == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if st, err := os.Stat(path); err == nil && !st.IsDir() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadJSON reads and unmarshals a JSON file into v.
|
|
||||||
// Returns os.ErrNotExist if the file does not exist.
|
|
||||||
func ReadJSON(path string, v any) error {
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return os.ErrNotExist
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(b) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return json.Unmarshal(b, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteJSON marshals v as JSON and writes to path, creating parent directories as needed.
|
|
||||||
func WriteJSON(path string, v any) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = f.Close() }()
|
|
||||||
enc := json.NewEncoder(f)
|
|
||||||
return enc.Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFile removes the file if it exists.
|
|
||||||
func RemoveFile(path string) error {
|
|
||||||
if IsRegularFile(path) {
|
|
||||||
return os.Remove(path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TryReadCookieSnapshotInto tries to read a cookie snapshot into v using the .cookie suffix.
|
|
||||||
// Returns (true, nil) when a snapshot was decoded, or (false, nil) when none exists.
|
|
||||||
func TryReadCookieSnapshotInto(mainPath string, v any) (bool, error) {
|
|
||||||
snap := CookieSnapshotPath(mainPath)
|
|
||||||
if err := ReadJSON(snap, v); err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteCookieSnapshot writes v to the snapshot path derived from mainPath using the .cookie suffix.
|
|
||||||
func WriteCookieSnapshot(mainPath string, v any) error {
|
|
||||||
path := CookieSnapshotPath(mainPath)
|
|
||||||
misc.LogSavingCredentials(path)
|
|
||||||
if err := WriteJSON(path, v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAuthFilePreferSnapshot returns the first non-empty auth payload preferring snapshots.
|
|
||||||
func ReadAuthFilePreferSnapshot(path string) ([]byte, error) {
|
|
||||||
return ReadAuthFileWithRetry(path, 1, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAuthFileWithRetry attempts to read an auth file multiple times and prefers cookie snapshots.
|
|
||||||
func ReadAuthFileWithRetry(path string, attempts int, delay time.Duration) ([]byte, error) {
|
|
||||||
if attempts < 1 {
|
|
||||||
attempts = 1
|
|
||||||
}
|
|
||||||
read := func(target string) ([]byte, error) {
|
|
||||||
var lastErr error
|
|
||||||
for i := 0; i < attempts; i++ {
|
|
||||||
data, err := os.ReadFile(target)
|
|
||||||
if err == nil {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
if i < attempts-1 {
|
|
||||||
time.Sleep(delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates := []string{
|
|
||||||
CookieSnapshotPath(path),
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, candidate := range candidates {
|
|
||||||
data, err := read(candidate)
|
|
||||||
if err == nil {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
if idx < len(candidates)-1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveCookieSnapshots removes the snapshot file if it exists.
|
|
||||||
func RemoveCookieSnapshots(mainPath string) {
|
|
||||||
_ = RemoveFile(CookieSnapshotPath(mainPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hooks provide customization points for snapshot lifecycle operations.
|
|
||||||
type Hooks[T any] struct {
|
|
||||||
// Apply merges snapshot data into the in-memory store during Apply().
|
|
||||||
// Defaults to overwriting the store with the snapshot contents.
|
|
||||||
Apply func(store *T, snapshot *T)
|
|
||||||
|
|
||||||
// Snapshot prepares the payload to persist during Persist().
|
|
||||||
// Defaults to cloning the store value.
|
|
||||||
Snapshot func(store *T) *T
|
|
||||||
|
|
||||||
// Merge chooses which data to flush when a snapshot exists.
|
|
||||||
// Defaults to using the snapshot payload as-is.
|
|
||||||
Merge func(store *T, snapshot *T) *T
|
|
||||||
|
|
||||||
// WriteMain persists the merged payload into the canonical token path.
|
|
||||||
// Defaults to WriteJSON.
|
|
||||||
WriteMain func(path string, data *T) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager orchestrates cookie snapshot lifecycle for token storages.
|
|
||||||
type Manager[T any] struct {
|
|
||||||
mainPath string
|
|
||||||
store *T
|
|
||||||
hooks Hooks[T]
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager constructs a Manager bound to mainPath and store.
|
|
||||||
func NewManager[T any](mainPath string, store *T, hooks Hooks[T]) *Manager[T] {
|
|
||||||
return &Manager[T]{
|
|
||||||
mainPath: mainPath,
|
|
||||||
store: store,
|
|
||||||
hooks: hooks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply loads snapshot data into the in-memory store if available.
|
|
||||||
// Returns true when a snapshot was applied.
|
|
||||||
func (m *Manager[T]) Apply() (bool, error) {
|
|
||||||
if m == nil || m.store == nil || m.mainPath == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
var snapshot T
|
|
||||||
ok, err := TryReadCookieSnapshotInto(m.mainPath, &snapshot)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if m.hooks.Apply != nil {
|
|
||||||
m.hooks.Apply(m.store, &snapshot)
|
|
||||||
} else {
|
|
||||||
*m.store = snapshot
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist writes the current store state to the snapshot file.
|
|
||||||
func (m *Manager[T]) Persist() error {
|
|
||||||
if m == nil || m.store == nil || m.mainPath == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var payload *T
|
|
||||||
if m.hooks.Snapshot != nil {
|
|
||||||
payload = m.hooks.Snapshot(m.store)
|
|
||||||
} else {
|
|
||||||
clone := new(T)
|
|
||||||
*clone = *m.store
|
|
||||||
payload = clone
|
|
||||||
}
|
|
||||||
return WriteCookieSnapshot(m.mainPath, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlushOptions configure Flush behaviour.
|
|
||||||
type FlushOptions[T any] struct {
|
|
||||||
Fallback func() *T
|
|
||||||
Mutate func(*T)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlushOption mutates FlushOptions.
|
|
||||||
type FlushOption[T any] func(*FlushOptions[T])
|
|
||||||
|
|
||||||
// WithFallback provides fallback payload when no snapshot exists.
|
|
||||||
func WithFallback[T any](fn func() *T) FlushOption[T] {
|
|
||||||
return func(opts *FlushOptions[T]) { opts.Fallback = fn }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush commits snapshot (or fallback) into the main token file and removes the snapshot.
|
|
||||||
func (m *Manager[T]) Flush(options ...FlushOption[T]) error {
|
|
||||||
if m == nil || m.mainPath == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg := FlushOptions[T]{}
|
|
||||||
for _, opt := range options {
|
|
||||||
if opt != nil {
|
|
||||||
opt(&cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var snapshot T
|
|
||||||
ok, err := TryReadCookieSnapshotInto(m.mainPath, &snapshot)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var payload *T
|
|
||||||
if ok {
|
|
||||||
if m.hooks.Merge != nil {
|
|
||||||
payload = m.hooks.Merge(m.store, &snapshot)
|
|
||||||
} else {
|
|
||||||
payload = &snapshot
|
|
||||||
}
|
|
||||||
} else if cfg.Fallback != nil {
|
|
||||||
payload = cfg.Fallback()
|
|
||||||
} else if m.store != nil {
|
|
||||||
payload = m.store
|
|
||||||
}
|
|
||||||
if payload == nil {
|
|
||||||
return RemoveFile(CookieSnapshotPath(m.mainPath))
|
|
||||||
}
|
|
||||||
if cfg.Mutate != nil {
|
|
||||||
cfg.Mutate(payload)
|
|
||||||
}
|
|
||||||
if m.hooks.WriteMain != nil {
|
|
||||||
if err = m.hooks.WriteMain(m.mainPath, payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err = WriteJSON(m.mainPath, payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RemoveCookieSnapshots(m.mainPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -69,8 +69,6 @@ type AuthUpdate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
authFileReadMaxAttempts = 5
|
|
||||||
authFileReadRetryDelay = 0
|
|
||||||
// replaceCheckDelay is a short delay to allow atomic replace (rename) to settle
|
// replaceCheckDelay is a short delay to allow atomic replace (rename) to settle
|
||||||
// before deciding whether a Remove event indicates a real deletion.
|
// before deciding whether a Remove event indicates a real deletion.
|
||||||
replaceCheckDelay = 50 * time.Millisecond
|
replaceCheckDelay = 50 * time.Millisecond
|
||||||
@@ -530,7 +528,7 @@ func (w *Watcher) reloadClients() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
|
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
|
||||||
if data, errReadAuthFileWithRetry := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); errReadAuthFileWithRetry == nil && len(data) > 0 {
|
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
|
||||||
sum := sha256.Sum256(data)
|
sum := sha256.Sum256(data)
|
||||||
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
@@ -565,7 +563,7 @@ func (w *Watcher) reloadClients() {
|
|||||||
|
|
||||||
// addOrUpdateClient handles the addition or update of a single client.
|
// addOrUpdateClient handles the addition or update of a single client.
|
||||||
func (w *Watcher) addOrUpdateClient(path string) {
|
func (w *Watcher) addOrUpdateClient(path string) {
|
||||||
data, errRead := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
|
data, errRead := os.ReadFile(path)
|
||||||
if errRead != nil {
|
if errRead != nil {
|
||||||
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
|
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
|
||||||
return
|
return
|
||||||
@@ -806,7 +804,7 @@ func (w *Watcher) loadFileClients(cfg *config.Config) int {
|
|||||||
authFileCount++
|
authFileCount++
|
||||||
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
|
log.Debugf("processing auth file %d: %s", authFileCount, filepath.Base(path))
|
||||||
// Count readable JSON files as successful auth entries
|
// Count readable JSON files as successful auth entries
|
||||||
if data, errCreate := util.ReadAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); errCreate == nil && len(data) > 0 {
|
if data, errCreate := os.ReadFile(path); errCreate == nil && len(data) > 0 {
|
||||||
successfulAuthCount++
|
successfulAuthCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/client/gemini-web"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||||
|
|||||||
Reference in New Issue
Block a user