diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index a0bff059..8a198a84 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1031,29 +1031,46 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { } fmt.Println("Authentication successful.") - if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil { - log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure) - oauthStatus[state] = "Failed to complete Gemini CLI onboarding" - return - } + if strings.EqualFold(requestedProjectID, "ALL") { + ts.Auto = false + projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts) + if errAll != nil { + log.Errorf("Failed to complete Gemini CLI onboarding: %v", errAll) + oauthStatus[state] = "Failed to complete Gemini CLI onboarding" + return + } + if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil { + log.Errorf("Failed to verify Cloud AI API status: %v", errVerify) + oauthStatus[state] = "Failed to verify Cloud AI API status" + return + } + ts.ProjectID = strings.Join(projects, ",") + ts.Checked = true + } else { + if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil { + log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure) + oauthStatus[state] = "Failed to complete Gemini CLI onboarding" + return + } - if strings.TrimSpace(ts.ProjectID) == "" { - log.Error("Onboarding did not return a project ID") - oauthStatus[state] = "Failed to resolve project ID" - return - } + if strings.TrimSpace(ts.ProjectID) == "" { + log.Error("Onboarding did not return a project ID") + oauthStatus[state] = "Failed to resolve project ID" + return + } - isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID) - if errCheck != nil { - log.Errorf("Failed to verify Cloud AI API status: %v", errCheck) - oauthStatus[state] = "Failed to verify Cloud AI API status" - return - } - ts.Checked = isChecked - if !isChecked { - log.Error("Cloud AI API is not enabled for the selected project") - oauthStatus[state] = "Cloud AI API not enabled" - return + isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID) + if errCheck != nil { + log.Errorf("Failed to verify Cloud AI API status: %v", errCheck) + oauthStatus[state] = "Failed to verify Cloud AI API status" + return + } + ts.Checked = isChecked + if !isChecked { + log.Error("Cloud AI API is not enabled for the selected project") + oauthStatus[state] = "Cloud AI API not enabled" + return + } } recordMetadata := map[string]any{ @@ -1063,10 +1080,11 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { "checked": ts.Checked, } + fileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true) record := &coreauth.Auth{ - ID: fmt.Sprintf("gemini-%s-%s.json", ts.Email, ts.ProjectID), + ID: fileName, Provider: "gemini", - FileName: fmt.Sprintf("gemini-%s-%s.json", ts.Email, ts.ProjectID), + FileName: fileName, Storage: &ts, Metadata: recordMetadata, } @@ -1459,6 +1477,57 @@ func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, return nil } +func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) { + projects, errProjects := fetchGCPProjects(ctx, httpClient) + if errProjects != nil { + return nil, fmt.Errorf("fetch project list: %w", errProjects) + } + if len(projects) == 0 { + return nil, fmt.Errorf("no Google Cloud projects available for this account") + } + activated := make([]string, 0, len(projects)) + seen := make(map[string]struct{}, len(projects)) + for _, project := range projects { + candidate := strings.TrimSpace(project.ProjectID) + if candidate == "" { + continue + } + if _, dup := seen[candidate]; dup { + continue + } + if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil { + return nil, fmt.Errorf("onboard project %s: %w", candidate, err) + } + finalID := strings.TrimSpace(storage.ProjectID) + if finalID == "" { + finalID = candidate + } + activated = append(activated, finalID) + seen[candidate] = struct{}{} + } + if len(activated) == 0 { + return nil, fmt.Errorf("no Google Cloud projects available for this account") + } + return activated, nil +} + +func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error { + for _, pid := range projectIDs { + trimmed := strings.TrimSpace(pid) + if trimmed == "" { + continue + } + isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed) + if errCheck != nil { + return fmt.Errorf("project %s: %w", trimmed, errCheck) + } + if !isChecked { + return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed) + } + } + return nil +} + func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error { metadata := map[string]string{ "ideType": "IDE_UNSPECIFIED", diff --git a/internal/auth/gemini/gemini_token.go b/internal/auth/gemini/gemini_token.go index 52b8acfa..0ec7da17 100644 --- a/internal/auth/gemini/gemini_token.go +++ b/internal/auth/gemini/gemini_token.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" log "github.com/sirupsen/logrus" @@ -67,3 +68,20 @@ func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { } return nil } + +// CredentialFileName returns the filename used to persist Gemini CLI credentials. +// When projectID represents multiple projects (comma-separated or literal ALL), +// the suffix is normalized to "all" and a "gemini-" prefix is enforced to keep +// web and CLI generated files consistent. +func CredentialFileName(email, projectID string, includeProviderPrefix bool) string { + email = strings.TrimSpace(email) + project := strings.TrimSpace(projectID) + if strings.EqualFold(project, "all") || strings.Contains(project, ",") { + return fmt.Sprintf("gemini-%s-all.json", email) + } + prefix := "" + if includeProviderPrefix { + prefix = "gemini-" + } + return fmt.Sprintf("%s%s-%s.json", prefix, email, project) +} diff --git a/internal/cmd/login.go b/internal/cmd/login.go index b6d6e2a7..5e5159aa 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -96,35 +96,52 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) { } selectedProjectID := promptForProjectSelection(projects, strings.TrimSpace(projectID), promptFn) - if strings.TrimSpace(selectedProjectID) == "" { + projectSelections, errSelection := resolveProjectSelections(selectedProjectID, projects) + if errSelection != nil { + log.Fatalf("Invalid project selection: %v", errSelection) + return + } + if len(projectSelections) == 0 { log.Fatal("No project selected; aborting login.") return } - if errSetup := performGeminiCLISetup(ctx, httpClient, storage, selectedProjectID); errSetup != nil { - var projectErr *projectSelectionRequiredError - if errors.As(errSetup, &projectErr) { - log.Error("Failed to start user onboarding: A project ID is required.") - showProjectSelectionHelp(storage.Email, projects) + activatedProjects := make([]string, 0, len(projectSelections)) + for _, candidateID := range projectSelections { + log.Infof("Activating project %s", candidateID) + if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil { + var projectErr *projectSelectionRequiredError + if errors.As(errSetup, &projectErr) { + log.Error("Failed to start user onboarding: A project ID is required.") + showProjectSelectionHelp(storage.Email, projects) + return + } + log.Fatalf("Failed to complete user setup: %v", errSetup) return } - log.Fatalf("Failed to complete user setup: %v", errSetup) - return + finalID := strings.TrimSpace(storage.ProjectID) + if finalID == "" { + finalID = candidateID + } + activatedProjects = append(activatedProjects, finalID) } storage.Auto = false + storage.ProjectID = strings.Join(activatedProjects, ",") if !storage.Auto && !storage.Checked { - isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, storage.ProjectID) - if errCheck != nil { - log.Fatalf("Failed to check if Cloud AI API is enabled: %v", errCheck) - return - } - storage.Checked = isChecked - if !isChecked { - log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.") - return + for _, pid := range activatedProjects { + isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, pid) + if errCheck != nil { + log.Fatalf("Failed to check if Cloud AI API is enabled for %s: %v", pid, errCheck) + return + } + if !isChecked { + log.Fatalf("Failed to check if Cloud AI API is enabled for project %s. If you encounter an error message, please create an issue.", pid) + return + } } + storage.Checked = true } updateAuthRecord(record, storage) @@ -354,10 +371,14 @@ func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetI defaultIndex = idx } } + fmt.Println("Type 'ALL' to onboard every listed project.") defaultID := projects[defaultIndex].ProjectID if trimmedPreset != "" { + if strings.EqualFold(trimmedPreset, "ALL") { + return "ALL" + } for _, project := range projects { if project.ProjectID == trimmedPreset { return trimmedPreset @@ -367,13 +388,16 @@ func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetI } for { - promptMsg := fmt.Sprintf("Enter project ID [%s]: ", defaultID) + promptMsg := fmt.Sprintf("Enter project ID [%s] or ALL: ", defaultID) answer, errPrompt := promptFn(promptMsg) if errPrompt != nil { log.Errorf("Project selection prompt failed: %v", errPrompt) return defaultID } answer = strings.TrimSpace(answer) + if strings.EqualFold(answer, "ALL") { + return "ALL" + } if answer == "" { return defaultID } @@ -394,6 +418,52 @@ func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetI } } +func resolveProjectSelections(selection string, projects []interfaces.GCPProjectProjects) ([]string, error) { + trimmed := strings.TrimSpace(selection) + if trimmed == "" { + return nil, nil + } + available := make(map[string]struct{}, len(projects)) + ordered := make([]string, 0, len(projects)) + for _, project := range projects { + id := strings.TrimSpace(project.ProjectID) + if id == "" { + continue + } + if _, exists := available[id]; exists { + continue + } + available[id] = struct{}{} + ordered = append(ordered, id) + } + if strings.EqualFold(trimmed, "ALL") { + if len(ordered) == 0 { + return nil, fmt.Errorf("no projects available for ALL selection") + } + return append([]string(nil), ordered...), nil + } + parts := strings.Split(trimmed, ",") + selections := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if _, dup := seen[id]; dup { + continue + } + if len(available) > 0 { + if _, ok := available[id]; !ok { + return nil, fmt.Errorf("project %s not found in available projects", id) + } + } + seen[id] = struct{}{} + selections = append(selections, id) + } + return selections, nil +} + func defaultProjectPrompt() func(string) (string, error) { reader := bufio.NewReader(os.Stdin) return func(prompt string) (string, error) { @@ -495,7 +565,7 @@ func updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStor return } - finalName := fmt.Sprintf("%s-%s.json", storage.Email, storage.ProjectID) + finalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, false) if record.Metadata == nil { record.Metadata = make(map[string]any) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index 8b491b9a..d0695b4d 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" @@ -80,7 +81,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth } } - projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id")) + projectID := resolveGeminiProjectID(auth) models := cliPreviewFallbackOrder(req.Model) if len(models) == 0 || models[0] != req.Model { models = append([]string{req.Model}, models...) @@ -214,7 +215,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) - projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id")) + projectID := resolveGeminiProjectID(auth) models := cliPreviewFallbackOrder(req.Model) if len(models) == 0 || models[0] != req.Model { @@ -493,12 +494,13 @@ func (e *GeminiCLIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth } func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) { - if auth == nil || auth.Metadata == nil { + metadata := geminiOAuthMetadata(auth) + if auth == nil || metadata == nil { return nil, nil, fmt.Errorf("gemini-cli auth metadata missing") } var base map[string]any - if tokenRaw, ok := auth.Metadata["token"].(map[string]any); ok && tokenRaw != nil { + if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil { base = cloneMap(tokenRaw) } else { base = make(map[string]any) @@ -512,16 +514,16 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth * } if token.AccessToken == "" { - token.AccessToken = stringValue(auth.Metadata, "access_token") + token.AccessToken = stringValue(metadata, "access_token") } if token.RefreshToken == "" { - token.RefreshToken = stringValue(auth.Metadata, "refresh_token") + token.RefreshToken = stringValue(metadata, "refresh_token") } if token.TokenType == "" { - token.TokenType = stringValue(auth.Metadata, "token_type") + token.TokenType = stringValue(metadata, "token_type") } if token.Expiry.IsZero() { - if expiry := stringValue(auth.Metadata, "expiry"); expiry != "" { + if expiry := stringValue(metadata, "expiry"); expiry != "" { if ts, err := time.Parse(time.RFC3339, expiry); err == nil { token.Expiry = ts } @@ -550,22 +552,28 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth * } func updateGeminiCLITokenMetadata(auth *cliproxyauth.Auth, base map[string]any, tok *oauth2.Token) { - if auth == nil || auth.Metadata == nil || tok == nil { + if auth == nil || tok == nil { return } - if tok.AccessToken != "" { - auth.Metadata["access_token"] = tok.AccessToken + merged := buildGeminiTokenMap(base, tok) + fields := buildGeminiTokenFields(tok, merged) + shared := geminicli.ResolveSharedCredential(auth.Runtime) + if shared != nil { + snapshot := shared.MergeMetadata(fields) + if !geminicli.IsVirtual(auth.Runtime) { + auth.Metadata = snapshot + } + return } - if tok.TokenType != "" { - auth.Metadata["token_type"] = tok.TokenType + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) } - if tok.RefreshToken != "" { - auth.Metadata["refresh_token"] = tok.RefreshToken - } - if !tok.Expiry.IsZero() { - auth.Metadata["expiry"] = tok.Expiry.Format(time.RFC3339) + for k, v := range fields { + auth.Metadata[k] = v } +} +func buildGeminiTokenMap(base map[string]any, tok *oauth2.Token) map[string]any { merged := cloneMap(base) if merged == nil { merged = make(map[string]any) @@ -578,8 +586,51 @@ func updateGeminiCLITokenMetadata(auth *cliproxyauth.Auth, base map[string]any, } } } + return merged +} - auth.Metadata["token"] = merged +func buildGeminiTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any { + fields := make(map[string]any, 5) + if tok.AccessToken != "" { + fields["access_token"] = tok.AccessToken + } + if tok.TokenType != "" { + fields["token_type"] = tok.TokenType + } + if tok.RefreshToken != "" { + fields["refresh_token"] = tok.RefreshToken + } + if !tok.Expiry.IsZero() { + fields["expiry"] = tok.Expiry.Format(time.RFC3339) + } + if len(merged) > 0 { + fields["token"] = cloneMap(merged) + } + return fields +} + +func resolveGeminiProjectID(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + if runtime := auth.Runtime; runtime != nil { + if virtual, ok := runtime.(*geminicli.VirtualCredential); ok && virtual != nil { + return strings.TrimSpace(virtual.ProjectID) + } + } + return strings.TrimSpace(stringValue(auth.Metadata, "project_id")) +} + +func geminiOAuthMetadata(auth *cliproxyauth.Auth) map[string]any { + if auth == nil { + return nil + } + if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil { + if snapshot := shared.MetadataSnapshot(); len(snapshot) > 0 { + return snapshot + } + } + return auth.Metadata } func newHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { diff --git a/internal/runtime/executor/usage_helpers.go b/internal/runtime/executor/usage_helpers.go index e0df5f3e..a3cd0922 100644 --- a/internal/runtime/executor/usage_helpers.go +++ b/internal/runtime/executor/usage_helpers.go @@ -9,7 +9,6 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" "github.com/tidwall/gjson" @@ -32,7 +31,7 @@ func newUsageReporter(ctx context.Context, provider, model string, auth *cliprox model: model, requestedAt: time.Now(), apiKey: apiKey, - source: util.HideAPIKey(resolveUsageSource(auth, apiKey)), + source: resolveUsageSource(auth, apiKey), } if auth != nil { reporter.authID = auth.ID @@ -130,6 +129,11 @@ func apiKeyFromContext(ctx context.Context) string { func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string { if auth != nil { provider := strings.TrimSpace(auth.Provider) + if strings.EqualFold(provider, "gemini-cli") { + if id := strings.TrimSpace(auth.ID); id != "" { + return id + } + } if strings.EqualFold(provider, "vertex") { if auth.Metadata != nil { if projectID, ok := auth.Metadata["project_id"].(string); ok { diff --git a/internal/runtime/geminicli/state.go b/internal/runtime/geminicli/state.go new file mode 100644 index 00000000..e323b44b --- /dev/null +++ b/internal/runtime/geminicli/state.go @@ -0,0 +1,144 @@ +package geminicli + +import ( + "strings" + "sync" +) + +// SharedCredential keeps canonical OAuth metadata for a multi-project Gemini CLI login. +type SharedCredential struct { + primaryID string + email string + metadata map[string]any + projectIDs []string + mu sync.RWMutex +} + +// NewSharedCredential builds a shared credential container for the given primary entry. +func NewSharedCredential(primaryID, email string, metadata map[string]any, projectIDs []string) *SharedCredential { + return &SharedCredential{ + primaryID: strings.TrimSpace(primaryID), + email: strings.TrimSpace(email), + metadata: cloneMap(metadata), + projectIDs: cloneStrings(projectIDs), + } +} + +// PrimaryID returns the owning credential identifier. +func (s *SharedCredential) PrimaryID() string { + if s == nil { + return "" + } + return s.primaryID +} + +// Email returns the associated account email. +func (s *SharedCredential) Email() string { + if s == nil { + return "" + } + return s.email +} + +// ProjectIDs returns a snapshot of the configured project identifiers. +func (s *SharedCredential) ProjectIDs() []string { + if s == nil { + return nil + } + return cloneStrings(s.projectIDs) +} + +// MetadataSnapshot returns a deep copy of the stored OAuth metadata. +func (s *SharedCredential) MetadataSnapshot() map[string]any { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return cloneMap(s.metadata) +} + +// MergeMetadata merges the provided fields into the shared metadata and returns an updated copy. +func (s *SharedCredential) MergeMetadata(values map[string]any) map[string]any { + if s == nil { + return nil + } + if len(values) == 0 { + return s.MetadataSnapshot() + } + s.mu.Lock() + defer s.mu.Unlock() + if s.metadata == nil { + s.metadata = make(map[string]any, len(values)) + } + for k, v := range values { + if v == nil { + delete(s.metadata, k) + continue + } + s.metadata[k] = v + } + return cloneMap(s.metadata) +} + +// SetProjectIDs updates the stored project identifiers. +func (s *SharedCredential) SetProjectIDs(ids []string) { + if s == nil { + return + } + s.mu.Lock() + s.projectIDs = cloneStrings(ids) + s.mu.Unlock() +} + +// VirtualCredential tracks a per-project virtual auth entry that reuses a primary credential. +type VirtualCredential struct { + ProjectID string + Parent *SharedCredential +} + +// NewVirtualCredential creates a virtual credential descriptor bound to the shared parent. +func NewVirtualCredential(projectID string, parent *SharedCredential) *VirtualCredential { + return &VirtualCredential{ProjectID: strings.TrimSpace(projectID), Parent: parent} +} + +// ResolveSharedCredential returns the shared credential backing the provided runtime payload. +func ResolveSharedCredential(runtime any) *SharedCredential { + switch typed := runtime.(type) { + case *SharedCredential: + return typed + case *VirtualCredential: + return typed.Parent + default: + return nil + } +} + +// IsVirtual reports whether the runtime payload represents a virtual credential. +func IsVirtual(runtime any) bool { + if runtime == nil { + return false + } + _, ok := runtime.(*VirtualCredential) + return ok +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneStrings(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 95810dd0..5dc64ce3 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -21,6 +21,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" "gopkg.in/yaml.v3" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" @@ -1026,11 +1027,119 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } + if provider == "gemini-cli" { + if virtuals := synthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 { + out = append(out, a) + out = append(out, virtuals...) + continue + } + } out = append(out, a) } return out } +func synthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]any, now time.Time) []*coreauth.Auth { + if primary == nil || metadata == nil { + return nil + } + projects := splitGeminiProjectIDs(metadata) + if len(projects) <= 1 { + return nil + } + email, _ := metadata["email"].(string) + shared := geminicli.NewSharedCredential(primary.ID, email, metadata, projects) + primary.Disabled = true + primary.Status = coreauth.StatusDisabled + primary.Runtime = shared + if primary.Attributes == nil { + primary.Attributes = make(map[string]string) + } + primary.Attributes["gemini_virtual_primary"] = "true" + primary.Attributes["virtual_children"] = strings.Join(projects, ",") + source := primary.Attributes["source"] + authPath := primary.Attributes["path"] + originalProvider := primary.Provider + if originalProvider == "" { + originalProvider = "gemini-cli" + } + label := primary.Label + if label == "" { + label = originalProvider + } + virtuals := make([]*coreauth.Auth, 0, len(projects)) + for _, projectID := range projects { + attrs := map[string]string{ + "runtime_only": "true", + "gemini_virtual_parent": primary.ID, + "gemini_virtual_project": projectID, + } + if source != "" { + attrs["source"] = source + } + if authPath != "" { + attrs["path"] = authPath + } + metadataCopy := map[string]any{ + "email": email, + "project_id": projectID, + "virtual": true, + "virtual_parent_id": primary.ID, + "type": metadata["type"], + } + proxy := strings.TrimSpace(primary.ProxyURL) + if proxy != "" { + metadataCopy["proxy_url"] = proxy + } + virtual := &coreauth.Auth{ + ID: buildGeminiVirtualID(primary.ID, projectID), + Provider: originalProvider, + Label: fmt.Sprintf("%s [%s]", label, projectID), + Status: coreauth.StatusActive, + Attributes: attrs, + Metadata: metadataCopy, + ProxyURL: primary.ProxyURL, + CreatedAt: now, + UpdatedAt: now, + Runtime: geminicli.NewVirtualCredential(projectID, shared), + } + virtuals = append(virtuals, virtual) + } + return virtuals +} + +func splitGeminiProjectIDs(metadata map[string]any) []string { + raw, _ := metadata["project_id"].(string) + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + parts := strings.Split(trimmed, ",") + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func buildGeminiVirtualID(baseID, projectID string) string { + project := strings.TrimSpace(projectID) + if project == "" { + project = "project" + } + replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_") + return fmt.Sprintf("%s::%s", baseID, replacer.Replace(project)) +} + // buildCombinedClientMap merges file-based clients with API key clients from the cache. // buildCombinedClientMap removed diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 57ad3295..d5f670d9 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -604,6 +604,12 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { if a == nil || a.ID == "" { return } + if a.Attributes != nil { + if v := strings.TrimSpace(a.Attributes["gemini_virtual_primary"]); strings.EqualFold(v, "true") { + GlobalModelRegistry().UnregisterClient(a.ID) + return + } + } // Unregister legacy client ID (if present) to avoid double counting if a.Runtime != nil { if idGetter, ok := a.Runtime.(interface{ GetClientID() string }); ok {