* fix(upstream): 修复上游格式兼容性问题 - 跳过Claude模型无signature的thinking block - 支持custom类型工具(MCP)格式转换 - 添加ClaudeCustomToolSpec结构体支持MCP工具 - 添加Custom字段验证,跳过无效custom工具 - 在convertClaudeToolsToGeminiTools中添加schema清理 - 完整的单元测试覆盖,包含边界情况 修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式 改进: Codex审查发现的2个重要问题 测试: - TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理 - TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况 - TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换 * feat(gemini): 添加Gemini限额与TierID支持 实现PR1:Gemini限额与TierID功能 后端修改: - GeminiTokenInfo结构体添加TierID字段 - fetchProjectID函数返回(projectID, tierID, error) - 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier) - ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID - BuildAccountCredentials函数保存tier_id到credentials 前端修改: - AccountStatusIndicator组件添加tier显示 - 支持LEGACY/PRO/ULTRA等tier类型的友好显示 - 使用蓝色badge展示tier信息 技术细节: - tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier - 所有fetchProjectID调用点已更新以处理新的返回签名 - 前端gracefully处理missing/unknown tier_id * refactor(gemini): 优化TierID实现并添加安全验证 根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进: 安全改进: - 添加validateTierID函数验证tier_id格式和长度(最大64字符) - 限制tier_id字符集为字母数字、下划线、连字符和斜杠 - 在BuildAccountCredentials中验证tier_id后再存储 - 静默跳过无效tier_id,不阻塞账户创建 代码质量改进: - 提取extractTierIDFromAllowedTiers辅助函数消除重复代码 - 重构fetchProjectID函数,tierID提取逻辑只执行一次 - 改进代码可读性和可维护性 审查工具: - code-reviewer agent (a09848e) - security-auditor agent (a9a149c) - gemini CLI (bcc7c81) - codex (b5d8919) 修复问题: - HIGH: 未验证的tier_id输入 - MEDIUM: 代码重复(tierID提取逻辑重复2次) * fix(format): 修复 gofmt 格式问题 - 修复 claude_types.go 中的字段对齐问题 - 修复 gemini_messages_compat_service.go 中的缩进问题 * fix(upstream): 修复上游格式兼容性问题 (#14) * fix(upstream): 修复上游格式兼容性问题 - 跳过Claude模型无signature的thinking block - 支持custom类型工具(MCP)格式转换 - 添加ClaudeCustomToolSpec结构体支持MCP工具 - 添加Custom字段验证,跳过无效custom工具 - 在convertClaudeToolsToGeminiTools中添加schema清理 - 完整的单元测试覆盖,包含边界情况 修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式 改进: Codex审查发现的2个重要问题 测试: - TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理 - TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况 - TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换 * fix(format): 修复 gofmt 格式问题 - 修复 claude_types.go 中的字段对齐问题 - 修复 gemini_messages_compat_service.go 中的缩进问题 * fix(format): 修复 claude_types.go 的 gofmt 格式问题 * feat(antigravity): 优化 thinking block 和 schema 处理 - 为 dummy thinking block 添加 ThoughtSignature - 重构 thinking block 处理逻辑,在每个条件分支内创建 part - 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段 (minItems, maxItems, minimum, maximum, additionalProperties, format) - 添加详细注释说明 Gemini API 支持的 schema 字段 * fix(antigravity): 增强 schema 清理的安全性 基于 Codex review 建议: - 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time - 补充更多不支持的 schema 关键字到黑名单: * 组合 schema: oneOf, anyOf, allOf, not, if/then/else * 对象验证: minProperties, maxProperties, patternProperties 等 * 定义引用: $defs, definitions - 避免不支持的 schema 字段导致 Gemini API 校验失败 * fix(lint): 修复 gemini_messages_compat_service 空分支警告 - 在 cleanToolSchema 的 if 语句中添加 continue - 移除重复的注释 * fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API - 将 minItems 和 maxItems 添加到 schema 黑名单 - Claude API (Vertex AI) 不支持这些数组验证字段 - 添加调试日志记录工具 schema 转换过程 - 修复 tools.14.custom.input_schema 验证错误 * fix(antigravity): 修复 additionalProperties schema 对象问题 - 将 additionalProperties 的 schema 对象转换为布尔值 true - Claude API 只支持 additionalProperties: false,不支持 schema 对象 - 修复 tools.14.custom.input_schema 验证错误 - 参考 Claude 官方文档的 JSON Schema 限制 * fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题 - 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败 - 只在 Gemini 模型中使用 dummy thought signature - 修改 additionalProperties 默认值为 false(更安全) - 添加调试日志以便排查问题 * fix(upstream): 修复跨模型切换时的 dummy signature 问题 基于 Codex review 和用户场景分析的修复: 1. 问题场景 - Gemini (thinking) → Claude (thinking) 切换时 - Gemini 返回的 thinking 块使用 dummy signature - Claude API 会拒绝 dummy signature,导致 400 错误 2. 修复内容 - request_transformer.go:262: 跳过 dummy signature - 只保留真实的 Claude signature - 支持频繁的跨模型切换 3. 其他修复(基于 Codex review) - gateway_service.go:691: 修复 io.ReadAll 错误处理 - gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置) - gateway_service.go:915: 收紧 400 failover 启发式 - request_transformer.go:188: 移除签名成功日志 4. 新增功能(默认关闭) - 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY) - 阶段 2: Antigravity thinking 修复 - 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY) - 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400) 测试:所有测试通过 * fix(lint): 修复 golangci-lint 问题 - 应用 De Morgan 定律简化条件判断 - 修复 gofmt 格式问题 - 移除未使用的 min 函数
599 lines
20 KiB
Go
599 lines
20 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||
)
|
||
|
||
type GeminiOAuthService struct {
|
||
sessionStore *geminicli.SessionStore
|
||
proxyRepo ProxyRepository
|
||
oauthClient GeminiOAuthClient
|
||
codeAssist GeminiCliCodeAssistClient
|
||
cfg *config.Config
|
||
}
|
||
|
||
type GeminiOAuthCapabilities struct {
|
||
AIStudioOAuthEnabled bool `json:"ai_studio_oauth_enabled"`
|
||
RequiredRedirectURIs []string `json:"required_redirect_uris"`
|
||
}
|
||
|
||
func NewGeminiOAuthService(
|
||
proxyRepo ProxyRepository,
|
||
oauthClient GeminiOAuthClient,
|
||
codeAssist GeminiCliCodeAssistClient,
|
||
cfg *config.Config,
|
||
) *GeminiOAuthService {
|
||
return &GeminiOAuthService{
|
||
sessionStore: geminicli.NewSessionStore(),
|
||
proxyRepo: proxyRepo,
|
||
oauthClient: oauthClient,
|
||
codeAssist: codeAssist,
|
||
cfg: cfg,
|
||
}
|
||
}
|
||
|
||
func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities {
|
||
// AI Studio OAuth is only enabled when the operator configures a custom OAuth client.
|
||
clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID)
|
||
clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret)
|
||
enabled := clientID != "" && clientSecret != "" &&
|
||
(clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret)
|
||
|
||
return &GeminiOAuthCapabilities{
|
||
AIStudioOAuthEnabled: enabled,
|
||
RequiredRedirectURIs: []string{geminicli.AIStudioOAuthRedirectURI},
|
||
}
|
||
}
|
||
|
||
type GeminiAuthURLResult struct {
|
||
AuthURL string `json:"auth_url"`
|
||
SessionID string `json:"session_id"`
|
||
State string `json:"state"`
|
||
}
|
||
|
||
func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, projectID, oauthType string) (*GeminiAuthURLResult, error) {
|
||
state, err := geminicli.GenerateState()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate state: %w", err)
|
||
}
|
||
codeVerifier, err := geminicli.GenerateCodeVerifier()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||
}
|
||
codeChallenge := geminicli.GenerateCodeChallenge(codeVerifier)
|
||
sessionID, err := geminicli.GenerateSessionID()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||
}
|
||
|
||
var proxyURL string
|
||
if proxyID != nil {
|
||
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
||
if err == nil && proxy != nil {
|
||
proxyURL = proxy.URL()
|
||
}
|
||
}
|
||
|
||
// OAuth client selection:
|
||
// - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret.
|
||
// - ai_studio: requires a user-provided OAuth client.
|
||
oauthCfg := geminicli.OAuthConfig{
|
||
ClientID: s.cfg.Gemini.OAuth.ClientID,
|
||
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
|
||
Scopes: s.cfg.Gemini.OAuth.Scopes,
|
||
}
|
||
if oauthType == "code_assist" {
|
||
oauthCfg.ClientID = ""
|
||
oauthCfg.ClientSecret = ""
|
||
}
|
||
|
||
session := &geminicli.OAuthSession{
|
||
State: state,
|
||
CodeVerifier: codeVerifier,
|
||
ProxyURL: proxyURL,
|
||
RedirectURI: redirectURI,
|
||
ProjectID: strings.TrimSpace(projectID),
|
||
OAuthType: oauthType,
|
||
CreatedAt: time.Now(),
|
||
}
|
||
s.sessionStore.Set(sessionID, session)
|
||
|
||
effectiveCfg, err := geminicli.EffectiveOAuthConfig(oauthCfg, oauthType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
||
|
||
// AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted).
|
||
if oauthType == "ai_studio" && isBuiltinClient {
|
||
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client (GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET). If you don't want to configure an OAuth client, please use an AI Studio API Key account instead")
|
||
}
|
||
|
||
// Redirect URI strategy:
|
||
// - code_assist: use Gemini CLI redirect URI (codeassist.google.com/authcode)
|
||
// - ai_studio: use localhost callback for manual copy/paste flow
|
||
if oauthType == "code_assist" {
|
||
redirectURI = geminicli.GeminiCLIRedirectURI
|
||
} else {
|
||
redirectURI = geminicli.AIStudioOAuthRedirectURI
|
||
}
|
||
session.RedirectURI = redirectURI
|
||
s.sessionStore.Set(sessionID, session)
|
||
|
||
authURL, err := geminicli.BuildAuthorizationURL(effectiveCfg, state, codeChallenge, redirectURI, session.ProjectID, oauthType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &GeminiAuthURLResult{
|
||
AuthURL: authURL,
|
||
SessionID: sessionID,
|
||
State: state,
|
||
}, nil
|
||
}
|
||
|
||
type GeminiExchangeCodeInput struct {
|
||
SessionID string
|
||
State string
|
||
Code string
|
||
ProxyID *int64
|
||
OAuthType string // "code_assist" 或 "ai_studio"
|
||
}
|
||
|
||
type GeminiTokenInfo struct {
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token"`
|
||
ExpiresIn int64 `json:"expires_in"`
|
||
ExpiresAt int64 `json:"expires_at"`
|
||
TokenType string `json:"token_type"`
|
||
Scope string `json:"scope,omitempty"`
|
||
ProjectID string `json:"project_id,omitempty"`
|
||
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
|
||
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA
|
||
}
|
||
|
||
// validateTierID validates tier_id format and length
|
||
func validateTierID(tierID string) error {
|
||
if tierID == "" {
|
||
return nil // Empty is allowed
|
||
}
|
||
if len(tierID) > 64 {
|
||
return fmt.Errorf("tier_id exceeds maximum length of 64 characters")
|
||
}
|
||
// Allow alphanumeric, underscore, hyphen, and slash (for tier paths)
|
||
if !regexp.MustCompile(`^[a-zA-Z0-9_/-]+$`).MatchString(tierID) {
|
||
return fmt.Errorf("tier_id contains invalid characters")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// extractTierIDFromAllowedTiers extracts tierID from LoadCodeAssist response
|
||
// Prioritizes IsDefault tier, falls back to first non-empty tier
|
||
func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string {
|
||
tierID := "LEGACY"
|
||
// First pass: look for default tier
|
||
for _, tier := range allowedTiers {
|
||
if tier.IsDefault && strings.TrimSpace(tier.ID) != "" {
|
||
tierID = strings.TrimSpace(tier.ID)
|
||
break
|
||
}
|
||
}
|
||
// Second pass: if still LEGACY, take first non-empty tier
|
||
if tierID == "LEGACY" {
|
||
for _, tier := range allowedTiers {
|
||
if strings.TrimSpace(tier.ID) != "" {
|
||
tierID = strings.TrimSpace(tier.ID)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return tierID
|
||
}
|
||
|
||
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
|
||
session, ok := s.sessionStore.Get(input.SessionID)
|
||
if !ok {
|
||
return nil, fmt.Errorf("session not found or expired")
|
||
}
|
||
if strings.TrimSpace(input.State) == "" || input.State != session.State {
|
||
return nil, fmt.Errorf("invalid state")
|
||
}
|
||
|
||
proxyURL := session.ProxyURL
|
||
if input.ProxyID != nil {
|
||
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
|
||
if err == nil && proxy != nil {
|
||
proxyURL = proxy.URL()
|
||
}
|
||
}
|
||
|
||
redirectURI := session.RedirectURI
|
||
|
||
// Resolve oauth_type early (defaults to code_assist for backward compatibility).
|
||
oauthType := session.OAuthType
|
||
if oauthType == "" {
|
||
oauthType = "code_assist"
|
||
}
|
||
|
||
// If the session was created for AI Studio OAuth, ensure a custom OAuth client is configured.
|
||
if oauthType == "ai_studio" {
|
||
effectiveCfg, err := geminicli.EffectiveOAuthConfig(geminicli.OAuthConfig{
|
||
ClientID: s.cfg.Gemini.OAuth.ClientID,
|
||
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
|
||
Scopes: s.cfg.Gemini.OAuth.Scopes,
|
||
}, "ai_studio")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
||
if isBuiltinClient {
|
||
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize")
|
||
}
|
||
}
|
||
|
||
// code_assist always uses the built-in client and its fixed redirect URI.
|
||
if oauthType == "code_assist" {
|
||
redirectURI = geminicli.GeminiCLIRedirectURI
|
||
}
|
||
|
||
tokenResp, err := s.oauthClient.ExchangeCode(ctx, oauthType, input.Code, session.CodeVerifier, redirectURI, proxyURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||
}
|
||
sessionProjectID := strings.TrimSpace(session.ProjectID)
|
||
s.sessionStore.Delete(input.SessionID)
|
||
|
||
// 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
|
||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||
|
||
projectID := sessionProjectID
|
||
var tierID string
|
||
|
||
// 对于 code_assist 模式,project_id 是必需的
|
||
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
|
||
if oauthType == "code_assist" {
|
||
if projectID == "" {
|
||
var err error
|
||
projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||
if err != nil {
|
||
// 记录警告但不阻断流程,允许后续补充 project_id
|
||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
||
}
|
||
}
|
||
if strings.TrimSpace(projectID) == "" {
|
||
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
|
||
}
|
||
}
|
||
|
||
return &GeminiTokenInfo{
|
||
AccessToken: tokenResp.AccessToken,
|
||
RefreshToken: tokenResp.RefreshToken,
|
||
TokenType: tokenResp.TokenType,
|
||
ExpiresIn: tokenResp.ExpiresIn,
|
||
ExpiresAt: expiresAt,
|
||
Scope: tokenResp.Scope,
|
||
ProjectID: projectID,
|
||
TierID: tierID,
|
||
OAuthType: oauthType,
|
||
}, nil
|
||
}
|
||
|
||
func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*GeminiTokenInfo, error) {
|
||
var lastErr error
|
||
|
||
for attempt := 0; attempt <= 3; attempt++ {
|
||
if attempt > 0 {
|
||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||
if backoff > 30*time.Second {
|
||
backoff = 30 * time.Second
|
||
}
|
||
time.Sleep(backoff)
|
||
}
|
||
|
||
tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
|
||
if err == nil {
|
||
// 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
|
||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||
return &GeminiTokenInfo{
|
||
AccessToken: tokenResp.AccessToken,
|
||
RefreshToken: tokenResp.RefreshToken,
|
||
TokenType: tokenResp.TokenType,
|
||
ExpiresIn: tokenResp.ExpiresIn,
|
||
ExpiresAt: expiresAt,
|
||
Scope: tokenResp.Scope,
|
||
}, nil
|
||
}
|
||
|
||
if isNonRetryableGeminiOAuthError(err) {
|
||
return nil, err
|
||
}
|
||
lastErr = err
|
||
}
|
||
|
||
return nil, fmt.Errorf("token refresh failed after retries: %w", lastErr)
|
||
}
|
||
|
||
func isNonRetryableGeminiOAuthError(err error) bool {
|
||
msg := err.Error()
|
||
nonRetryable := []string{
|
||
"invalid_grant",
|
||
"invalid_client",
|
||
"unauthorized_client",
|
||
"access_denied",
|
||
}
|
||
for _, needle := range nonRetryable {
|
||
if strings.Contains(msg, needle) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GeminiTokenInfo, error) {
|
||
if account.Platform != PlatformGemini || account.Type != AccountTypeOAuth {
|
||
return nil, fmt.Errorf("account is not a Gemini OAuth account")
|
||
}
|
||
|
||
refreshToken := account.GetCredential("refresh_token")
|
||
if strings.TrimSpace(refreshToken) == "" {
|
||
return nil, fmt.Errorf("no refresh token available")
|
||
}
|
||
|
||
// Preserve oauth_type from the account (defaults to code_assist for backward compatibility).
|
||
oauthType := strings.TrimSpace(account.GetCredential("oauth_type"))
|
||
if oauthType == "" {
|
||
oauthType = "code_assist"
|
||
}
|
||
|
||
var proxyURL string
|
||
if account.ProxyID != nil {
|
||
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||
if err == nil && proxy != nil {
|
||
proxyURL = proxy.URL()
|
||
}
|
||
}
|
||
|
||
tokenInfo, err := s.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
|
||
// Backward compatibility:
|
||
// Older versions could refresh Code Assist tokens using a user-provided OAuth client when configured.
|
||
// If the refresh token was originally issued to that custom client, forcing the built-in client will
|
||
// fail with "unauthorized_client". In that case, retry with the custom client (ai_studio path) when available.
|
||
if err != nil && oauthType == "code_assist" && strings.Contains(err.Error(), "unauthorized_client") && s.GetOAuthConfig().AIStudioOAuthEnabled {
|
||
if alt, altErr := s.RefreshToken(ctx, "ai_studio", refreshToken, proxyURL); altErr == nil {
|
||
tokenInfo = alt
|
||
err = nil
|
||
}
|
||
}
|
||
if err != nil {
|
||
// Provide a more actionable error for common OAuth client mismatch issues.
|
||
if strings.Contains(err.Error(), "unauthorized_client") {
|
||
return nil, fmt.Errorf("%w (OAuth client mismatch: the refresh_token is bound to the OAuth client used during authorization; please re-authorize this account or restore the original GEMINI_OAUTH_CLIENT_ID/SECRET)", err)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
tokenInfo.OAuthType = oauthType
|
||
|
||
// Preserve account's project_id when present.
|
||
existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||
if existingProjectID != "" {
|
||
tokenInfo.ProjectID = existingProjectID
|
||
}
|
||
|
||
// For Code Assist, project_id is required. Auto-detect if missing.
|
||
// For AI Studio OAuth, project_id is optional and should not block refresh.
|
||
if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
|
||
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
|
||
}
|
||
projectID = strings.TrimSpace(projectID)
|
||
if projectID == "" {
|
||
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
||
}
|
||
tokenInfo.ProjectID = projectID
|
||
tokenInfo.TierID = tierID
|
||
}
|
||
|
||
return tokenInfo, nil
|
||
}
|
||
|
||
func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo) map[string]any {
|
||
creds := map[string]any{
|
||
"access_token": tokenInfo.AccessToken,
|
||
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
||
}
|
||
if tokenInfo.RefreshToken != "" {
|
||
creds["refresh_token"] = tokenInfo.RefreshToken
|
||
}
|
||
if tokenInfo.TokenType != "" {
|
||
creds["token_type"] = tokenInfo.TokenType
|
||
}
|
||
if tokenInfo.Scope != "" {
|
||
creds["scope"] = tokenInfo.Scope
|
||
}
|
||
if tokenInfo.ProjectID != "" {
|
||
creds["project_id"] = tokenInfo.ProjectID
|
||
}
|
||
if tokenInfo.TierID != "" {
|
||
// Validate tier_id before storing
|
||
if err := validateTierID(tokenInfo.TierID); err == nil {
|
||
creds["tier_id"] = tokenInfo.TierID
|
||
}
|
||
// Silently skip invalid tier_id (don't block account creation)
|
||
}
|
||
if tokenInfo.OAuthType != "" {
|
||
creds["oauth_type"] = tokenInfo.OAuthType
|
||
}
|
||
return creds
|
||
}
|
||
|
||
func (s *GeminiOAuthService) Stop() {
|
||
s.sessionStore.Stop()
|
||
}
|
||
|
||
func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, proxyURL string) (string, string, error) {
|
||
if s.codeAssist == nil {
|
||
return "", "", errors.New("code assist client not configured")
|
||
}
|
||
|
||
loadResp, loadErr := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil)
|
||
|
||
// Extract tierID from response (works whether CloudAICompanionProject is set or not)
|
||
tierID := "LEGACY"
|
||
if loadResp != nil {
|
||
tierID = extractTierIDFromAllowedTiers(loadResp.AllowedTiers)
|
||
}
|
||
|
||
// If LoadCodeAssist returned a project, use it
|
||
if loadErr == nil && loadResp != nil && strings.TrimSpace(loadResp.CloudAICompanionProject) != "" {
|
||
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
|
||
}
|
||
|
||
// Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
|
||
// (tierID already extracted above, reuse it)
|
||
|
||
req := &geminicli.OnboardUserRequest{
|
||
TierID: tierID,
|
||
Metadata: geminicli.LoadCodeAssistMetadata{
|
||
IDEType: "ANTIGRAVITY",
|
||
Platform: "PLATFORM_UNSPECIFIED",
|
||
PluginType: "GEMINI",
|
||
},
|
||
}
|
||
|
||
maxAttempts := 5
|
||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||
resp, err := s.codeAssist.OnboardUser(ctx, accessToken, proxyURL, req)
|
||
if err != nil {
|
||
// If Code Assist onboarding fails (e.g. INVALID_ARGUMENT), fallback to Cloud Resource Manager projects.
|
||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||
return strings.TrimSpace(fallback), tierID, nil
|
||
}
|
||
return "", "", err
|
||
}
|
||
if resp.Done {
|
||
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
|
||
switch v := resp.Response.CloudAICompanionProject.(type) {
|
||
case string:
|
||
return strings.TrimSpace(v), tierID, nil
|
||
case map[string]any:
|
||
if id, ok := v["id"].(string); ok {
|
||
return strings.TrimSpace(id), tierID, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||
return strings.TrimSpace(fallback), tierID, nil
|
||
}
|
||
return "", "", errors.New("onboardUser completed but no project_id returned")
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
|
||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||
return strings.TrimSpace(fallback), tierID, nil
|
||
}
|
||
if loadErr != nil {
|
||
return "", "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
|
||
}
|
||
return "", "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
|
||
}
|
||
|
||
type googleCloudProject struct {
|
||
ProjectID string `json:"projectId"`
|
||
DisplayName string `json:"name"`
|
||
LifecycleState string `json:"lifecycleState"`
|
||
}
|
||
|
||
type googleCloudProjectsResponse struct {
|
||
Projects []googleCloudProject `json:"projects"`
|
||
}
|
||
|
||
func fetchProjectIDFromResourceManager(ctx context.Context, accessToken, proxyURL string) (string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to create resource manager request: %w", err)
|
||
}
|
||
|
||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||
|
||
client, err := httpclient.GetClient(httpclient.Options{
|
||
ProxyURL: strings.TrimSpace(proxyURL),
|
||
Timeout: 30 * time.Second,
|
||
})
|
||
if err != nil {
|
||
client = &http.Client{Timeout: 30 * time.Second}
|
||
}
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("resource manager request failed: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
|
||
bodyBytes, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to read resource manager response: %w", err)
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", fmt.Errorf("resource manager HTTP %d: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
var projectsResp googleCloudProjectsResponse
|
||
if err := json.Unmarshal(bodyBytes, &projectsResp); err != nil {
|
||
return "", fmt.Errorf("failed to parse resource manager response: %w", err)
|
||
}
|
||
|
||
active := make([]googleCloudProject, 0, len(projectsResp.Projects))
|
||
for _, p := range projectsResp.Projects {
|
||
if p.LifecycleState == "ACTIVE" && strings.TrimSpace(p.ProjectID) != "" {
|
||
active = append(active, p)
|
||
}
|
||
}
|
||
if len(active) == 0 {
|
||
return "", errors.New("no ACTIVE projects found from resource manager")
|
||
}
|
||
|
||
// Prefer likely companion projects first.
|
||
for _, p := range active {
|
||
id := strings.ToLower(strings.TrimSpace(p.ProjectID))
|
||
name := strings.ToLower(strings.TrimSpace(p.DisplayName))
|
||
if strings.Contains(id, "cloud-ai-companion") || strings.Contains(name, "cloud ai companion") || strings.Contains(name, "code assist") {
|
||
return strings.TrimSpace(p.ProjectID), nil
|
||
}
|
||
}
|
||
// Then prefer "default".
|
||
for _, p := range active {
|
||
id := strings.ToLower(strings.TrimSpace(p.ProjectID))
|
||
name := strings.ToLower(strings.TrimSpace(p.DisplayName))
|
||
if strings.Contains(id, "default") || strings.Contains(name, "default") {
|
||
return strings.TrimSpace(p.ProjectID), nil
|
||
}
|
||
}
|
||
|
||
return strings.TrimSpace(active[0].ProjectID), nil
|
||
}
|