feat(config): 新增 Gemini 配置项和 geminicli 核心包
- 添加 Gemini OAuth 配置结构 - 实现 geminicli 包(OAuth、Token、CodeAssist 类型) - 更新配置示例文件
This commit is contained in:
@@ -18,6 +18,17 @@ type Config struct {
|
|||||||
Gateway GatewayConfig `mapstructure:"gateway"`
|
Gateway GatewayConfig `mapstructure:"gateway"`
|
||||||
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
||||||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||||||
|
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeminiConfig struct {
|
||||||
|
OAuth GeminiOAuthConfig `mapstructure:"oauth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeminiOAuthConfig struct {
|
||||||
|
ClientID string `mapstructure:"client_id"`
|
||||||
|
ClientSecret string `mapstructure:"client_secret"`
|
||||||
|
Scopes string `mapstructure:"scopes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenRefreshConfig OAuth token自动刷新配置
|
// TokenRefreshConfig OAuth token自动刷新配置
|
||||||
@@ -214,6 +225,11 @@ func setDefaults() {
|
|||||||
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 1.5) // 提前1.5小时刷新
|
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 1.5) // 提前1.5小时刷新
|
||||||
viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次
|
viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次
|
||||||
viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒
|
viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒
|
||||||
|
|
||||||
|
// Gemini (optional)
|
||||||
|
viper.SetDefault("gemini.oauth.client_id", "")
|
||||||
|
viper.SetDefault("gemini.oauth.client_secret", "")
|
||||||
|
viper.SetDefault("gemini.oauth.scopes", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
|
|||||||
38
backend/internal/pkg/geminicli/codeassist_types.go
Normal file
38
backend/internal/pkg/geminicli/codeassist_types.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package geminicli
|
||||||
|
|
||||||
|
// LoadCodeAssistRequest matches done-hub's internal Code Assist call.
|
||||||
|
type LoadCodeAssistRequest struct {
|
||||||
|
Metadata LoadCodeAssistMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadCodeAssistMetadata struct {
|
||||||
|
IDEType string `json:"ideType"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
PluginType string `json:"pluginType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadCodeAssistResponse struct {
|
||||||
|
CurrentTier string `json:"currentTier,omitempty"`
|
||||||
|
CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"`
|
||||||
|
AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllowedTier struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IsDefault bool `json:"isDefault,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnboardUserRequest struct {
|
||||||
|
TierID string `json:"tierId"`
|
||||||
|
Metadata LoadCodeAssistMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnboardUserResponse struct {
|
||||||
|
Done bool `json:"done"`
|
||||||
|
Response *OnboardUserResultData `json:"response,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnboardUserResultData struct {
|
||||||
|
CloudAICompanionProject any `json:"cloudaicompanionProject,omitempty"`
|
||||||
|
}
|
||||||
20
backend/internal/pkg/geminicli/constants.go
Normal file
20
backend/internal/pkg/geminicli/constants.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package geminicli
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
|
||||||
|
GeminiCliBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||||
|
|
||||||
|
AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
TokenURL = "https://oauth2.googleapis.com/token"
|
||||||
|
|
||||||
|
// DefaultScopes is the minimal scope set for GeminiCli/CodeAssist usage.
|
||||||
|
// Keep this conservative and expand only when we have a clear requirement.
|
||||||
|
DefaultScopes = "https://www.googleapis.com/auth/cloud-platform"
|
||||||
|
|
||||||
|
SessionTTL = 30 * time.Minute
|
||||||
|
|
||||||
|
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
|
||||||
|
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
|
||||||
|
)
|
||||||
167
backend/internal/pkg/geminicli/oauth.go
Normal file
167
backend/internal/pkg/geminicli/oauth.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package geminicli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OAuthConfig struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
Scopes string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthSession struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
CodeVerifier string `json:"code_verifier"`
|
||||||
|
ProxyURL string `json:"proxy_url,omitempty"`
|
||||||
|
RedirectURI string `json:"redirect_uri"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*OAuthSession
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
store := &SessionStore{
|
||||||
|
sessions: make(map[string]*OAuthSession),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go store.cleanup()
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[sessionID] = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
session, ok := s.sessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if time.Since(session.CreatedAt) > SessionTTL {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return session, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Delete(sessionID string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Stop() {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
close(s.stopCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) cleanup() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.mu.Lock()
|
||||||
|
for id, session := range s.sessions {
|
||||||
|
if time.Since(session.CreatedAt) > SessionTTL {
|
||||||
|
delete(s.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateState() (string, error) {
|
||||||
|
bytes, err := GenerateRandomBytes(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64URLEncode(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSessionID() (string, error) {
|
||||||
|
bytes, err := GenerateRandomBytes(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCodeVerifier returns an RFC 7636 compatible code verifier (43+ chars).
|
||||||
|
func GenerateCodeVerifier() (string, error) {
|
||||||
|
bytes, err := GenerateRandomBytes(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64URLEncode(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateCodeChallenge(verifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64URLEncode(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64URLEncode(data []byte) string {
|
||||||
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI string) (string, error) {
|
||||||
|
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||||
|
return "", fmt.Errorf("gemini oauth client_id is empty")
|
||||||
|
}
|
||||||
|
redirectURI = strings.TrimSpace(redirectURI)
|
||||||
|
if redirectURI == "" {
|
||||||
|
return "", fmt.Errorf("redirect_uri is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes := strings.TrimSpace(cfg.Scopes)
|
||||||
|
if scopes == "" {
|
||||||
|
scopes = DefaultScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("response_type", "code")
|
||||||
|
params.Set("client_id", cfg.ClientID)
|
||||||
|
params.Set("redirect_uri", redirectURI)
|
||||||
|
params.Set("scope", scopes)
|
||||||
|
params.Set("state", state)
|
||||||
|
params.Set("code_challenge", codeChallenge)
|
||||||
|
params.Set("code_challenge_method", "S256")
|
||||||
|
params.Set("access_type", "offline")
|
||||||
|
params.Set("prompt", "consent")
|
||||||
|
params.Set("include_granted_scopes", "true")
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil
|
||||||
|
}
|
||||||
46
backend/internal/pkg/geminicli/sanitize.go
Normal file
46
backend/internal/pkg/geminicli/sanitize.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package geminicli
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const maxLogBodyLen = 2048
|
||||||
|
|
||||||
|
func SanitizeBodyForLogs(body string) string {
|
||||||
|
body = truncateBase64InMessage(body)
|
||||||
|
if len(body) > maxLogBodyLen {
|
||||||
|
body = body[:maxLogBodyLen] + "...[truncated]"
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateBase64InMessage(message string) string {
|
||||||
|
const maxBase64Length = 50
|
||||||
|
|
||||||
|
result := message
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
idx := strings.Index(result[offset:], ";base64,")
|
||||||
|
if idx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
actualIdx := offset + idx
|
||||||
|
start := actualIdx + len(";base64,")
|
||||||
|
|
||||||
|
end := start
|
||||||
|
for end < len(result) && isBase64Char(result[end]) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
if end-start > maxBase64Length {
|
||||||
|
result = result[:start+maxBase64Length] + "...[truncated]" + result[end:]
|
||||||
|
offset = start + maxBase64Length + len("...[truncated]")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBase64Char(c byte) bool {
|
||||||
|
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '='
|
||||||
|
}
|
||||||
9
backend/internal/pkg/geminicli/token_types.go
Normal file
9
backend/internal/pkg/geminicli/token_types.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package geminicli
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
@@ -87,3 +87,14 @@ pricing:
|
|||||||
update_interval_hours: 24
|
update_interval_hours: 24
|
||||||
# Hash check interval in minutes
|
# Hash check interval in minutes
|
||||||
hash_check_interval_minutes: 10
|
hash_check_interval_minutes: 10
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Gemini (Optional)
|
||||||
|
# =============================================================================
|
||||||
|
gemini:
|
||||||
|
oauth:
|
||||||
|
# Google OAuth Client ID / Secret (for GeminiCli / Code Assist internal API)
|
||||||
|
client_id: ""
|
||||||
|
client_secret: ""
|
||||||
|
# Optional scopes (space-separated). Leave empty to use default cloud-platform scope.
|
||||||
|
scopes: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user