mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 13:00:52 +08:00
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.
This commit is contained in:
@@ -1031,29 +1031,46 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
fmt.Println("Authentication successful.")
|
fmt.Println("Authentication successful.")
|
||||||
|
|
||||||
if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
|
if strings.EqualFold(requestedProjectID, "ALL") {
|
||||||
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
|
ts.Auto = false
|
||||||
oauthStatus[state] = "Failed to complete Gemini CLI onboarding"
|
projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts)
|
||||||
return
|
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) == "" {
|
if strings.TrimSpace(ts.ProjectID) == "" {
|
||||||
log.Error("Onboarding did not return a project ID")
|
log.Error("Onboarding did not return a project ID")
|
||||||
oauthStatus[state] = "Failed to resolve project ID"
|
oauthStatus[state] = "Failed to resolve project ID"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
|
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
|
||||||
if errCheck != nil {
|
if errCheck != nil {
|
||||||
log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
|
log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
|
||||||
oauthStatus[state] = "Failed to verify Cloud AI API status"
|
oauthStatus[state] = "Failed to verify Cloud AI API status"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ts.Checked = isChecked
|
ts.Checked = isChecked
|
||||||
if !isChecked {
|
if !isChecked {
|
||||||
log.Error("Cloud AI API is not enabled for the selected project")
|
log.Error("Cloud AI API is not enabled for the selected project")
|
||||||
oauthStatus[state] = "Cloud AI API not enabled"
|
oauthStatus[state] = "Cloud AI API not enabled"
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordMetadata := map[string]any{
|
recordMetadata := map[string]any{
|
||||||
@@ -1063,10 +1080,11 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
|||||||
"checked": ts.Checked,
|
"checked": ts.Checked,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true)
|
||||||
record := &coreauth.Auth{
|
record := &coreauth.Auth{
|
||||||
ID: fmt.Sprintf("gemini-%s-%s.json", ts.Email, ts.ProjectID),
|
ID: fileName,
|
||||||
Provider: "gemini",
|
Provider: "gemini",
|
||||||
FileName: fmt.Sprintf("gemini-%s-%s.json", ts.Email, ts.ProjectID),
|
FileName: fileName,
|
||||||
Storage: &ts,
|
Storage: &ts,
|
||||||
Metadata: recordMetadata,
|
Metadata: recordMetadata,
|
||||||
}
|
}
|
||||||
@@ -1459,6 +1477,57 @@ func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client,
|
|||||||
return nil
|
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 {
|
func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"ideType": "IDE_UNSPECIFIED",
|
"ideType": "IDE_UNSPECIFIED",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -67,3 +68,20 @@ func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,35 +96,52 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedProjectID := promptForProjectSelection(projects, strings.TrimSpace(projectID), promptFn)
|
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.")
|
log.Fatal("No project selected; aborting login.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, selectedProjectID); errSetup != nil {
|
activatedProjects := make([]string, 0, len(projectSelections))
|
||||||
var projectErr *projectSelectionRequiredError
|
for _, candidateID := range projectSelections {
|
||||||
if errors.As(errSetup, &projectErr) {
|
log.Infof("Activating project %s", candidateID)
|
||||||
log.Error("Failed to start user onboarding: A project ID is required.")
|
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil {
|
||||||
showProjectSelectionHelp(storage.Email, projects)
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Fatalf("Failed to complete user setup: %v", errSetup)
|
finalID := strings.TrimSpace(storage.ProjectID)
|
||||||
return
|
if finalID == "" {
|
||||||
|
finalID = candidateID
|
||||||
|
}
|
||||||
|
activatedProjects = append(activatedProjects, finalID)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.Auto = false
|
storage.Auto = false
|
||||||
|
storage.ProjectID = strings.Join(activatedProjects, ",")
|
||||||
|
|
||||||
if !storage.Auto && !storage.Checked {
|
if !storage.Auto && !storage.Checked {
|
||||||
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, storage.ProjectID)
|
for _, pid := range activatedProjects {
|
||||||
if errCheck != nil {
|
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, pid)
|
||||||
log.Fatalf("Failed to check if Cloud AI API is enabled: %v", errCheck)
|
if errCheck != nil {
|
||||||
return
|
log.Fatalf("Failed to check if Cloud AI API is enabled for %s: %v", pid, errCheck)
|
||||||
}
|
return
|
||||||
storage.Checked = isChecked
|
}
|
||||||
if !isChecked {
|
if !isChecked {
|
||||||
log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.")
|
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
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
storage.Checked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAuthRecord(record, storage)
|
updateAuthRecord(record, storage)
|
||||||
@@ -354,10 +371,14 @@ func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetI
|
|||||||
defaultIndex = idx
|
defaultIndex = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fmt.Println("Type 'ALL' to onboard every listed project.")
|
||||||
|
|
||||||
defaultID := projects[defaultIndex].ProjectID
|
defaultID := projects[defaultIndex].ProjectID
|
||||||
|
|
||||||
if trimmedPreset != "" {
|
if trimmedPreset != "" {
|
||||||
|
if strings.EqualFold(trimmedPreset, "ALL") {
|
||||||
|
return "ALL"
|
||||||
|
}
|
||||||
for _, project := range projects {
|
for _, project := range projects {
|
||||||
if project.ProjectID == trimmedPreset {
|
if project.ProjectID == trimmedPreset {
|
||||||
return trimmedPreset
|
return trimmedPreset
|
||||||
@@ -367,13 +388,16 @@ func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetI
|
|||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
promptMsg := fmt.Sprintf("Enter project ID [%s]: ", defaultID)
|
promptMsg := fmt.Sprintf("Enter project ID [%s] or ALL: ", defaultID)
|
||||||
answer, errPrompt := promptFn(promptMsg)
|
answer, errPrompt := promptFn(promptMsg)
|
||||||
if errPrompt != nil {
|
if errPrompt != nil {
|
||||||
log.Errorf("Project selection prompt failed: %v", errPrompt)
|
log.Errorf("Project selection prompt failed: %v", errPrompt)
|
||||||
return defaultID
|
return defaultID
|
||||||
}
|
}
|
||||||
answer = strings.TrimSpace(answer)
|
answer = strings.TrimSpace(answer)
|
||||||
|
if strings.EqualFold(answer, "ALL") {
|
||||||
|
return "ALL"
|
||||||
|
}
|
||||||
if answer == "" {
|
if answer == "" {
|
||||||
return defaultID
|
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) {
|
func defaultProjectPrompt() func(string) (string, error) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
return func(prompt string) (string, error) {
|
return func(prompt string) (string, error) {
|
||||||
@@ -495,7 +565,7 @@ func updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStor
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
finalName := fmt.Sprintf("%s-%s.json", storage.Email, storage.ProjectID)
|
finalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, false)
|
||||||
|
|
||||||
if record.Metadata == nil {
|
if record.Metadata == nil {
|
||||||
record.Metadata = make(map[string]any)
|
record.Metadata = make(map[string]any)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"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/misc"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
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)
|
models := cliPreviewFallbackOrder(req.Model)
|
||||||
if len(models) == 0 || models[0] != req.Model {
|
if len(models) == 0 || models[0] != req.Model {
|
||||||
models = append([]string{req.Model}, models...)
|
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 = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||||
|
|
||||||
projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id"))
|
projectID := resolveGeminiProjectID(auth)
|
||||||
|
|
||||||
models := cliPreviewFallbackOrder(req.Model)
|
models := cliPreviewFallbackOrder(req.Model)
|
||||||
if len(models) == 0 || models[0] != 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) {
|
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")
|
return nil, nil, fmt.Errorf("gemini-cli auth metadata missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
var base map[string]any
|
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)
|
base = cloneMap(tokenRaw)
|
||||||
} else {
|
} else {
|
||||||
base = make(map[string]any)
|
base = make(map[string]any)
|
||||||
@@ -512,16 +514,16 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *
|
|||||||
}
|
}
|
||||||
|
|
||||||
if token.AccessToken == "" {
|
if token.AccessToken == "" {
|
||||||
token.AccessToken = stringValue(auth.Metadata, "access_token")
|
token.AccessToken = stringValue(metadata, "access_token")
|
||||||
}
|
}
|
||||||
if token.RefreshToken == "" {
|
if token.RefreshToken == "" {
|
||||||
token.RefreshToken = stringValue(auth.Metadata, "refresh_token")
|
token.RefreshToken = stringValue(metadata, "refresh_token")
|
||||||
}
|
}
|
||||||
if token.TokenType == "" {
|
if token.TokenType == "" {
|
||||||
token.TokenType = stringValue(auth.Metadata, "token_type")
|
token.TokenType = stringValue(metadata, "token_type")
|
||||||
}
|
}
|
||||||
if token.Expiry.IsZero() {
|
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 {
|
if ts, err := time.Parse(time.RFC3339, expiry); err == nil {
|
||||||
token.Expiry = ts
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if tok.AccessToken != "" {
|
merged := buildGeminiTokenMap(base, tok)
|
||||||
auth.Metadata["access_token"] = tok.AccessToken
|
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 != "" {
|
if auth.Metadata == nil {
|
||||||
auth.Metadata["token_type"] = tok.TokenType
|
auth.Metadata = make(map[string]any)
|
||||||
}
|
}
|
||||||
if tok.RefreshToken != "" {
|
for k, v := range fields {
|
||||||
auth.Metadata["refresh_token"] = tok.RefreshToken
|
auth.Metadata[k] = v
|
||||||
}
|
|
||||||
if !tok.Expiry.IsZero() {
|
|
||||||
auth.Metadata["expiry"] = tok.Expiry.Format(time.RFC3339)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGeminiTokenMap(base map[string]any, tok *oauth2.Token) map[string]any {
|
||||||
merged := cloneMap(base)
|
merged := cloneMap(base)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
merged = make(map[string]any)
|
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 {
|
func newHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -32,7 +31,7 @@ func newUsageReporter(ctx context.Context, provider, model string, auth *cliprox
|
|||||||
model: model,
|
model: model,
|
||||||
requestedAt: time.Now(),
|
requestedAt: time.Now(),
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
source: util.HideAPIKey(resolveUsageSource(auth, apiKey)),
|
source: resolveUsageSource(auth, apiKey),
|
||||||
}
|
}
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
reporter.authID = auth.ID
|
reporter.authID = auth.ID
|
||||||
@@ -130,6 +129,11 @@ func apiKeyFromContext(ctx context.Context) string {
|
|||||||
func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string {
|
func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string {
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
provider := strings.TrimSpace(auth.Provider)
|
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 strings.EqualFold(provider, "vertex") {
|
||||||
if auth.Metadata != nil {
|
if auth.Metadata != nil {
|
||||||
if projectID, ok := auth.Metadata["project_id"].(string); ok {
|
if projectID, ok := auth.Metadata["project_id"].(string); ok {
|
||||||
|
|||||||
144
internal/runtime/geminicli/state.go
Normal file
144
internal/runtime/geminicli/state.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
@@ -1026,11 +1027,119 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
return out
|
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 merges file-based clients with API key clients from the cache.
|
||||||
// buildCombinedClientMap removed
|
// buildCombinedClientMap removed
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,12 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
if a == nil || a.ID == "" {
|
if a == nil || a.ID == "" {
|
||||||
return
|
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
|
// Unregister legacy client ID (if present) to avoid double counting
|
||||||
if a.Runtime != nil {
|
if a.Runtime != nil {
|
||||||
if idGetter, ok := a.Runtime.(interface{ GetClientID() string }); ok {
|
if idGetter, ok := a.Runtime.(interface{ GetClientID() string }); ok {
|
||||||
|
|||||||
Reference in New Issue
Block a user