From 040dc27ea5c6d4a9417bf92ca0250b72f832cee4 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Thu, 25 Dec 2025 21:24:22 -0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E6=B7=BB=E5=8A=A0=20Gemini/Go?= =?UTF-8?q?ogle=20API=20=E5=9F=BA=E7=A1=80=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 pkg/gemini: 模型定义与回退列表 - 新增 pkg/googleapi: Google API 错误状态处理 - 新增 pkg/geminicli/models.go: CLI 模型结构 - 更新 constants.go: AI Studio 相关常量 - 更新 oauth.go: 支持 AI Studio OAuth 流程,凭据通过环境变量配置 --- backend/internal/pkg/gemini/models.go | 43 ++++++++++++++++++ backend/internal/pkg/geminicli/constants.go | 20 +++++++-- backend/internal/pkg/geminicli/models.go | 22 +++++++++ backend/internal/pkg/geminicli/oauth.go | 50 ++++++++++++++++----- backend/internal/pkg/googleapi/status.go | 25 +++++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 backend/internal/pkg/gemini/models.go create mode 100644 backend/internal/pkg/geminicli/models.go create mode 100644 backend/internal/pkg/googleapi/status.go diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go new file mode 100644 index 00000000..8dc52c3f --- /dev/null +++ b/backend/internal/pkg/gemini/models.go @@ -0,0 +1,43 @@ +package gemini + +// This package provides minimal fallback model metadata for Gemini native endpoints. +// It is used when upstream model listing is unavailable (e.g. OAuth token missing AI Studio scopes). + +type Model struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods,omitempty"` +} + +type ModelsListResponse struct { + Models []Model `json:"models"` +} + +func DefaultModels() []Model { + methods := []string{"generateContent", "streamGenerateContent"} + return []Model{ + {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.0-flash-lite", SupportedGenerationMethods: methods}, + {Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods}, + {Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods}, + {Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods}, + } +} + +func FallbackModelsList() ModelsListResponse { + return ModelsListResponse{Models: DefaultModels()} +} + +func FallbackModel(model string) Model { + methods := []string{"generateContent", "streamGenerateContent"} + if model == "" { + return Model{Name: "models/unknown", SupportedGenerationMethods: methods} + } + if len(model) >= 7 && model[:7] == "models/" { + return Model{Name: model, SupportedGenerationMethods: methods} + } + return Model{Name: "models/" + model, SupportedGenerationMethods: methods} +} + diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go index 7ad33d75..8dd8c645 100644 --- a/backend/internal/pkg/geminicli/constants.go +++ b/backend/internal/pkg/geminicli/constants.go @@ -9,9 +9,23 @@ const ( 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" + // AIStudioOAuthRedirectURI is the default redirect URI used for AI Studio OAuth. + // This matches the "copy/paste callback URL" flow used by OpenAI OAuth in this project. + // Note: You still need to register this redirect URI in your Google OAuth client + // unless you use an OAuth client type that permits localhost redirect URIs. + AIStudioOAuthRedirectURI = "http://localhost:1455/auth/callback" + + // DefaultScopes for Code Assist (includes cloud-platform for API access plus userinfo scopes) + // Required by Google's Code Assist API. + DefaultCodeAssistScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + + // DefaultScopes for AI Studio (uses generativelanguage API with OAuth) + // Reference: https://ai.google.dev/gemini-api/docs/oauth + // For regular Google accounts, supports API calls to generativelanguage.googleapis.com + DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language" + + // GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth. + GeminiCLIRedirectURI = "https://codeassist.google.com/authcode" SessionTTL = 30 * time.Minute diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go new file mode 100644 index 00000000..39d2d54a --- /dev/null +++ b/backend/internal/pkg/geminicli/models.go @@ -0,0 +1,22 @@ +package geminicli + +// Model represents a selectable Gemini model for UI/testing purposes. +// Keep JSON fields consistent with existing frontend expectations. +type Model struct { + ID string `json:"id"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` +} + +// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow. +var DefaultModels = []Model{ + {ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""}, + {ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", CreatedAt: ""}, + {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, + {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, +} + +// DefaultTestModel is the default model to preselect in test flows. +const DefaultTestModel = "gemini-2.5-pro" + diff --git a/backend/internal/pkg/geminicli/oauth.go b/backend/internal/pkg/geminicli/oauth.go index 2b6cf714..3e469c4b 100644 --- a/backend/internal/pkg/geminicli/oauth.go +++ b/backend/internal/pkg/geminicli/oauth.go @@ -23,6 +23,8 @@ type OAuthSession struct { CodeVerifier string `json:"code_verifier"` ProxyURL string `json:"proxy_url,omitempty"` RedirectURI string `json:"redirect_uri"` + ProjectID string `json:"project_id,omitempty"` + OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio" CreatedAt time.Time `json:"created_at"` } @@ -137,31 +139,59 @@ 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") +// EffectiveOAuthConfig returns the effective OAuth configuration. +// oauthType: "code_assist" or "ai_studio" (defaults to "code_assist" if empty) +// Returns error if ClientID or ClientSecret is not configured. +// Configure via GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET environment variables. +func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error) { + effective := OAuthConfig{ + ClientID: strings.TrimSpace(cfg.ClientID), + ClientSecret: strings.TrimSpace(cfg.ClientSecret), + Scopes: strings.TrimSpace(cfg.Scopes), + } + + // Require OAuth credentials to be configured + if effective.ClientID == "" || effective.ClientSecret == "" { + return OAuthConfig{}, fmt.Errorf("Gemini OAuth credentials not configured. Set GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET environment variables") + } + + if effective.Scopes == "" { + // Use different default scopes based on OAuth type + if oauthType == "ai_studio" { + effective.Scopes = DefaultAIStudioScopes + } else { + // Default to Code Assist scopes + effective.Scopes = DefaultCodeAssistScopes + } + } + + return effective, nil +} + +func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) { + effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType) + if err != nil { + return "", err } 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("client_id", effectiveCfg.ClientID) params.Set("redirect_uri", redirectURI) - params.Set("scope", scopes) + params.Set("scope", effectiveCfg.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") + if strings.TrimSpace(projectID) != "" { + params.Set("project_id", strings.TrimSpace(projectID)) + } return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil } diff --git a/backend/internal/pkg/googleapi/status.go b/backend/internal/pkg/googleapi/status.go new file mode 100644 index 00000000..b0850609 --- /dev/null +++ b/backend/internal/pkg/googleapi/status.go @@ -0,0 +1,25 @@ +package googleapi + +import "net/http" + +// HTTPStatusToGoogleStatus maps HTTP status codes to Google-style error status strings. +func HTTPStatusToGoogleStatus(status int) string { + switch status { + case http.StatusBadRequest: + return "INVALID_ARGUMENT" + case http.StatusUnauthorized: + return "UNAUTHENTICATED" + case http.StatusForbidden: + return "PERMISSION_DENIED" + case http.StatusNotFound: + return "NOT_FOUND" + case http.StatusTooManyRequests: + return "RESOURCE_EXHAUSTED" + default: + if status >= 500 { + return "INTERNAL" + } + return "UNKNOWN" + } +} +