Compare commits

...

6 Commits

Author SHA1 Message Date
Luis Pater
a7d2f669e7 feat(watcher): expand event handling for config and auth JSON updates
Refine `handleEvent` to support additional file system operations (Rename, Remove) for config and auth JSON files. Improve client update/removal logic with atomic file replacement handling and incremental processing for auth changes.
2025-11-13 12:13:31 +08:00
Luis Pater
ce569ab36e feat(buildinfo): add build metadata and expose via HTTP headers
Introduce a new `buildinfo` package to store version, commit, and build date metadata. Update HTTP handlers to include build metadata in response headers and modify initialization to set `buildinfo` values during runtime.
2025-11-13 08:38:03 +08:00
Luis Pater
d0aa741d59 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.
2025-11-13 02:55:32 +08:00
Luis Pater
592f6fc66b feat(vertex): add usage source resolution for Vertex projects
Extend `resolveUsageSource` to support Vertex projects by extracting and normalizing `project_id` or `project` from the metadata for accurate source resolution.
2025-11-12 08:43:02 +08:00
Luis Pater
09ecba6dab Merge pull request #237 from TUGOhost/feature/support_auto_model
feat: add auto model resolution and model creation timestamp tracking
2025-11-12 00:03:30 +08:00
TUGOhost
92f4278039 feat: add auto model resolution and model creation timestamp tracking
- Add 'created' field to model registry for tracking model creation time
- Implement GetFirstAvailableModel() to find the first available model by newest creation timestamp
- Add ResolveAutoModel() utility function to resolve "auto" model name to actual available model
- Update request handler to resolve "auto" model before processing requests
- Ensures automatic model selection when "auto" is specified as model name

This enables dynamic model selection based on availability and creation time, improving the user experience when no specific model is requested.
2025-11-11 20:30:09 +08:00
14 changed files with 664 additions and 75 deletions

View File

@@ -17,6 +17,7 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
"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/logging" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
@@ -41,13 +42,16 @@ var (
// init initializes the shared logger setup. // init initializes the shared logger setup.
func init() { func init() {
logging.SetupBaseLogger() logging.SetupBaseLogger()
buildinfo.Version = Version
buildinfo.Commit = Commit
buildinfo.BuildDate = BuildDate
} }
// main is the entry point of the application. // main is the entry point of the application.
// It parses command-line flags, loads configuration, and starts the appropriate // It parses command-line flags, loads configuration, and starts the appropriate
// service based on the provided flags (login, codex-login, or server mode). // service based on the provided flags (login, codex-login, or server mode).
func main() { func main() {
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", Version, Commit, BuildDate) fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
// Command-line flags to control the application's behavior. // Command-line flags to control the application's behavior.
var login bool var login bool
@@ -386,7 +390,7 @@ func main() {
log.Fatalf("failed to configure log output: %v", err) log.Fatalf("failed to configure log output: %v", err)
} }
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate) log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
// Set the log level based on the configuration. // Set the log level based on the configuration.
util.SetLogLevel(cfg) util.SetLogLevel(cfg)

View File

@@ -1031,6 +1031,22 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
} }
fmt.Println("Authentication successful.") fmt.Println("Authentication successful.")
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 { if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure) log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
oauthStatus[state] = "Failed to complete Gemini CLI onboarding" oauthStatus[state] = "Failed to complete Gemini CLI onboarding"
@@ -1055,6 +1071,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
oauthStatus[state] = "Cloud AI API not enabled" oauthStatus[state] = "Cloud AI API not enabled"
return return
} }
}
recordMetadata := map[string]any{ recordMetadata := map[string]any{
"email": ts.Email, "email": ts.Email,
@@ -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",

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
"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/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
@@ -91,6 +92,10 @@ func (h *Handler) Middleware() gin.HandlerFunc {
const banDuration = 30 * time.Minute const banDuration = 30 * time.Minute
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("X-CPA-VERSION", buildinfo.Version)
c.Header("X-CPA-COMMIT", buildinfo.Commit)
c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate)
clientIP := c.ClientIP() clientIP := c.ClientIP()
localClient := clientIP == "127.0.0.1" || clientIP == "::1" localClient := clientIP == "127.0.0.1" || clientIP == "::1"
cfg := h.cfg cfg := h.cfg

View File

@@ -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)
}

View File

@@ -0,0 +1,15 @@
// Package buildinfo exposes compile-time metadata shared across the server.
package buildinfo
// The following variables are overridden via ldflags during release builds.
// Defaults cover local development builds.
var (
// Version is the semantic version or git describe output of the binary.
Version = "dev"
// Commit is the git commit SHA baked into the binary.
Commit = "none"
// BuildDate records when the binary was built in UTC.
BuildDate = "unknown"
)

View File

@@ -96,12 +96,20 @@ 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))
for _, candidateID := range projectSelections {
log.Infof("Activating project %s", candidateID)
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil {
var projectErr *projectSelectionRequiredError var projectErr *projectSelectionRequiredError
if errors.As(errSetup, &projectErr) { if errors.As(errSetup, &projectErr) {
log.Error("Failed to start user onboarding: A project ID is required.") log.Error("Failed to start user onboarding: A project ID is required.")
@@ -111,21 +119,30 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
log.Fatalf("Failed to complete user setup: %v", errSetup) log.Fatalf("Failed to complete user setup: %v", errSetup)
return return
} }
finalID := strings.TrimSpace(storage.ProjectID)
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 {
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, pid)
if errCheck != nil { if errCheck != nil {
log.Fatalf("Failed to check if Cloud AI API is enabled: %v", errCheck) log.Fatalf("Failed to check if Cloud AI API is enabled for %s: %v", pid, errCheck)
return 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)

View File

@@ -4,6 +4,7 @@
package registry package registry
import ( import (
"fmt"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -800,6 +801,9 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
if model.Type != "" { if model.Type != "" {
result["type"] = model.Type result["type"] = model.Type
} }
if model.Created != 0 {
result["created"] = model.Created
}
return result return result
} }
} }
@@ -821,3 +825,47 @@ func (r *ModelRegistry) CleanupExpiredQuotas() {
} }
} }
} }
// GetFirstAvailableModel returns the first available model for the given handler type.
// It prioritizes models by their creation timestamp (newest first) and checks if they have
// available clients that are not suspended or over quota.
//
// Parameters:
// - handlerType: The API handler type (e.g., "openai", "claude", "gemini")
//
// Returns:
// - string: The model ID of the first available model, or empty string if none available
// - error: An error if no models are available
func (r *ModelRegistry) GetFirstAvailableModel(handlerType string) (string, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
// Get all available models for this handler type
models := r.GetAvailableModels(handlerType)
if len(models) == 0 {
return "", fmt.Errorf("no models available for handler type: %s", handlerType)
}
// Sort models by creation timestamp (newest first)
sort.Slice(models, func(i, j int) bool {
// Extract created timestamps from map
createdI, okI := models[i]["created"].(int64)
createdJ, okJ := models[j]["created"].(int64)
if !okI || !okJ {
return false
}
return createdI > createdJ
})
// Find the first model with available clients
for _, model := range models {
if modelID, ok := model["id"].(string); ok {
if count := r.GetModelCount(modelID); count > 0 {
return modelID, nil
}
}
}
return "", fmt.Errorf("no available clients for any model in handler type: %s", handlerType)
}

View File

@@ -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
} }
if tok.TokenType != "" { return
auth.Metadata["token_type"] = tok.TokenType
} }
if tok.RefreshToken != "" { if auth.Metadata == nil {
auth.Metadata["refresh_token"] = tok.RefreshToken auth.Metadata = make(map[string]any)
} }
if !tok.Expiry.IsZero() { for k, v := range fields {
auth.Metadata["expiry"] = tok.Expiry.Format(time.RFC3339) auth.Metadata[k] = v
} }
}
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 {

View File

@@ -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
@@ -129,6 +128,26 @@ 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)
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 {
if trimmed := strings.TrimSpace(projectID); trimmed != "" {
return trimmed
}
}
if project, ok := auth.Metadata["project"].(string); ok {
if trimmed := strings.TrimSpace(project); trimmed != "" {
return trimmed
}
}
}
}
if _, value := auth.AccountInfo(); value != "" { if _, value := auth.AccountInfo(); value != "" {
return strings.TrimSpace(value) return strings.TrimSpace(value)
} }

View 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
}

View File

@@ -9,6 +9,7 @@ import (
"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/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
log "github.com/sirupsen/logrus"
) )
// GetProviderName determines all AI service providers capable of serving a registered model. // GetProviderName determines all AI service providers capable of serving a registered model.
@@ -59,6 +60,30 @@ func GetProviderName(modelName string) []string {
return providers return providers
} }
// ResolveAutoModel resolves the "auto" model name to an actual available model.
// It uses an empty handler type to get any available model from the registry.
//
// Parameters:
// - modelName: The model name to check (should be "auto")
//
// Returns:
// - string: The resolved model name, or the original if not "auto" or resolution fails
func ResolveAutoModel(modelName string) string {
if modelName != "auto" {
return modelName
}
// Use empty string as handler type to get any available model
firstModel, err := registry.GetGlobalRegistry().GetFirstAvailableModel("")
if err != nil {
log.Warnf("Failed to resolve 'auto' model: %v, falling back to original model name", err)
return modelName
}
log.Infof("Resolved 'auto' model to: %s", firstModel)
return firstModel
}
// IsOpenAICompatibilityAlias checks if the given model name is an alias // IsOpenAICompatibilityAlias checks if the given model name is an alias
// configured for OpenAI compatibility routing. // configured for OpenAI compatibility routing.
// //

View File

@@ -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"
@@ -476,8 +477,10 @@ func (w *Watcher) processEvents(ctx context.Context) {
// handleEvent processes individual file system events // handleEvent processes individual file system events
func (w *Watcher) handleEvent(event fsnotify.Event) { func (w *Watcher) handleEvent(event fsnotify.Event) {
// Filter only relevant events: config file or auth-dir JSON files. // Filter only relevant events: config file or auth-dir JSON files.
isConfigEvent := event.Name == w.configPath && (event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create) configOps := fsnotify.Write | fsnotify.Create | fsnotify.Rename
isAuthJSON := strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") isConfigEvent := event.Name == w.configPath && event.Op&configOps != 0
authOps := fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename
isAuthJSON := strings.HasPrefix(event.Name, w.authDir) && strings.HasSuffix(event.Name, ".json") && event.Op&authOps != 0
if !isConfigEvent && !isAuthJSON { if !isConfigEvent && !isAuthJSON {
// Ignore unrelated files (e.g., cookie snapshots *.cookie) and other noise. // Ignore unrelated files (e.g., cookie snapshots *.cookie) and other noise.
return return
@@ -495,18 +498,19 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
// Handle auth directory changes incrementally (.json only) // Handle auth directory changes incrementally (.json only)
fmt.Printf("auth file changed (%s): %s, processing incrementally\n", event.Op.String(), filepath.Base(event.Name)) fmt.Printf("auth file changed (%s): %s, processing incrementally\n", event.Op.String(), filepath.Base(event.Name))
if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write { if event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 {
w.addOrUpdateClient(event.Name) // Atomic replace on some platforms may surface as Rename (or Remove) before the new file is ready.
} else if event.Op&fsnotify.Remove == fsnotify.Remove { // Wait briefly; if the path exists again, treat as an update instead of removal.
// Atomic replace on some platforms may surface as Remove+Create for the target path.
// Wait briefly; if the file exists again, treat as update instead of removal.
time.Sleep(replaceCheckDelay) time.Sleep(replaceCheckDelay)
if _, statErr := os.Stat(event.Name); statErr == nil { if _, statErr := os.Stat(event.Name); statErr == nil {
// File exists after a short delay; handle as an update.
w.addOrUpdateClient(event.Name) w.addOrUpdateClient(event.Name)
return return
} }
w.removeClient(event.Name) w.removeClient(event.Name)
return
}
if event.Op&(fsnotify.Create|fsnotify.Write) != 0 {
w.addOrUpdateClient(event.Name)
} }
} }
@@ -1026,11 +1030,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

View File

@@ -295,11 +295,14 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
} }
func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string, normalizedModel string, metadata map[string]any, err *interfaces.ErrorMessage) { func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string, normalizedModel string, metadata map[string]any, err *interfaces.ErrorMessage) {
providerName, extractedModelName, isDynamic := h.parseDynamicModel(modelName) // Resolve "auto" model to an actual available model first
resolvedModelName := util.ResolveAutoModel(modelName)
providerName, extractedModelName, isDynamic := h.parseDynamicModel(resolvedModelName)
// First, normalize the model name to handle suffixes like "-thinking-128" // First, normalize the model name to handle suffixes like "-thinking-128"
// This needs to happen before determining the provider for non-dynamic models. // This needs to happen before determining the provider for non-dynamic models.
normalizedModel, metadata = normalizeModelMetadata(modelName) normalizedModel, metadata = normalizeModelMetadata(resolvedModelName)
if isDynamic { if isDynamic {
providers = []string{providerName} providers = []string{providerName}

View File

@@ -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 {