From d0aa741d59de77bce55c00748fbce18488fc4984 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 13 Nov 2025 02:55:32 +0800 Subject: [PATCH] feat(gemini-cli): add multi-project support and enhance credential handling Introduce support for multi-project Gemini CLI logins, including shared and virtual credential management. Enhance runtime, metadata handling, and token updates for better project granularity and consistency across virtual and shared credentials. Extend onboarding to allow activating all available projects. --- .../api/handlers/management/auth_files.go | 115 +++++++++++--- internal/auth/gemini/gemini_token.go | 18 +++ internal/cmd/login.go | 108 ++++++++++--- .../runtime/executor/gemini_cli_executor.go | 89 ++++++++--- internal/runtime/executor/usage_helpers.go | 8 +- internal/runtime/geminicli/state.go | 144 ++++++++++++++++++ internal/watcher/watcher.go | 109 +++++++++++++ sdk/cliproxy/service.go | 6 + 8 files changed, 534 insertions(+), 63 deletions(-) create mode 100644 internal/runtime/geminicli/state.go 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 {