package management import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "path/filepath" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"} const ( anthropicCallbackPort = 54545 geminiCallbackPort = 8085 codexCallbackPort = 1455 geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com" geminiCLIVersion = "v1internal" geminiCLIUserAgent = "google-api-nodejs-client/9.15.1" geminiCLIApiClient = "gl-node/22.17.0" geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI" ) type callbackForwarder struct { provider string server *http.Server done chan struct{} } var ( callbackForwardersMu sync.Mutex callbackForwarders = make(map[int]*callbackForwarder) ) func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) { if len(meta) == 0 { return time.Time{}, false } for _, key := range lastRefreshKeys { if val, ok := meta[key]; ok { if ts, ok1 := parseLastRefreshValue(val); ok1 { return ts, true } } } return time.Time{}, false } func parseLastRefreshValue(v any) (time.Time, bool) { switch val := v.(type) { case string: s := strings.TrimSpace(val) if s == "" { return time.Time{}, false } layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"} for _, layout := range layouts { if ts, err := time.Parse(layout, s); err == nil { return ts.UTC(), true } } if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 { return time.Unix(unix, 0).UTC(), true } case float64: if val <= 0 { return time.Time{}, false } return time.Unix(int64(val), 0).UTC(), true case int64: if val <= 0 { return time.Time{}, false } return time.Unix(val, 0).UTC(), true case int: if val <= 0 { return time.Time{}, false } return time.Unix(int64(val), 0).UTC(), true case json.Number: if i, err := val.Int64(); err == nil && i > 0 { return time.Unix(i, 0).UTC(), true } } return time.Time{}, false } func isWebUIRequest(c *gin.Context) bool { raw := strings.TrimSpace(c.Query("is_webui")) if raw == "" { return false } switch strings.ToLower(raw) { case "1", "true", "yes", "on": return true default: return false } } func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) { callbackForwardersMu.Lock() prev := callbackForwarders[port] if prev != nil { delete(callbackForwarders, port) } callbackForwardersMu.Unlock() if prev != nil { stopForwarderInstance(port, prev) } addr := fmt.Sprintf("127.0.0.1:%d", port) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("failed to listen on %s: %w", addr, err) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target := targetBase if raw := r.URL.RawQuery; raw != "" { if strings.Contains(target, "?") { target = target + "&" + raw } else { target = target + "?" + raw } } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, target, http.StatusFound) }) srv := &http.Server{ Handler: handler, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } done := make(chan struct{}) go func() { if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider) } close(done) }() forwarder := &callbackForwarder{ provider: provider, server: srv, done: done, } callbackForwardersMu.Lock() callbackForwarders[port] = forwarder callbackForwardersMu.Unlock() log.Infof("callback forwarder for %s listening on %s", provider, addr) return forwarder, nil } func stopCallbackForwarder(port int) { callbackForwardersMu.Lock() forwarder := callbackForwarders[port] if forwarder != nil { delete(callbackForwarders, port) } callbackForwardersMu.Unlock() stopForwarderInstance(port, forwarder) } func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) { if forwarder == nil { return } callbackForwardersMu.Lock() if current := callbackForwarders[port]; current == forwarder { delete(callbackForwarders, port) } callbackForwardersMu.Unlock() stopForwarderInstance(port, forwarder) } func stopForwarderInstance(port int, forwarder *callbackForwarder) { if forwarder == nil || forwarder.server == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port) } select { case <-forwarder.done: case <-time.After(2 * time.Second): } log.Infof("callback forwarder on port %d stopped", port) } func (h *Handler) managementCallbackURL(path string) (string, error) { if h == nil || h.cfg == nil || h.cfg.Port <= 0 { return "", fmt.Errorf("server port is not configured") } if !strings.HasPrefix(path, "/") { path = "/" + path } scheme := "http" if h.cfg.TLS.Enable { scheme = "https" } return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil } func authEmail(auth *coreauth.Auth) string { if auth == nil { return "" } if auth.Metadata != nil { if v, ok := auth.Metadata["email"].(string); ok { return strings.TrimSpace(v) } } if auth.Attributes != nil { if v := strings.TrimSpace(auth.Attributes["email"]); v != "" { return v } if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" { return v } } return "" } func authAttribute(auth *coreauth.Auth, key string) string { if auth == nil || len(auth.Attributes) == 0 { return "" } return auth.Attributes[key] } func isRuntimeOnlyAuth(auth *coreauth.Auth) bool { if auth == nil || len(auth.Attributes) == 0 { return false } return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true") } // Download single auth file by name func (h *Handler) authIDForPath(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if h == nil || h.cfg == nil { return path } authDir := strings.TrimSpace(h.cfg.AuthDir) if authDir == "" { return path } if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" { return rel } return path } func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("auth path is empty") } store := h.tokenStoreWithBaseDir() if store == nil { return fmt.Errorf("token store unavailable") } return store.Delete(ctx, path) } func (h *Handler) tokenStoreWithBaseDir() coreauth.Store { if h == nil { return nil } store := h.tokenStore if store == nil { store = sdkAuth.GetTokenStore() h.tokenStore = store } if h.cfg != nil { if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok { dirSetter.SetBaseDir(h.cfg.AuthDir) } } return store } func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) { if record == nil { return "", fmt.Errorf("token record is nil") } store := h.tokenStoreWithBaseDir() if store == nil { return "", fmt.Errorf("token store unavailable") } return store.Save(ctx, record) } func (e *projectSelectionRequiredError) Error() string { return "gemini cli: project selection required" } func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error { if storage == nil { return fmt.Errorf("gemini storage is nil") } trimmedRequest := strings.TrimSpace(requestedProject) if trimmedRequest == "" { projects, errProjects := fetchGCPProjects(ctx, httpClient) if errProjects != nil { return fmt.Errorf("fetch project list: %w", errProjects) } if len(projects) == 0 { return fmt.Errorf("no Google Cloud projects available for this account") } trimmedRequest = strings.TrimSpace(projects[0].ProjectID) if trimmedRequest == "" { return fmt.Errorf("resolved project id is empty") } storage.Auto = true } else { storage.Auto = false } if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil { return err } if strings.TrimSpace(storage.ProjectID) == "" { storage.ProjectID = trimmedRequest } 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", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI", } trimmedRequest := strings.TrimSpace(requestedProject) explicitProject := trimmedRequest != "" loadReqBody := map[string]any{ "metadata": metadata, } if explicitProject { loadReqBody["cloudaicompanionProject"] = trimmedRequest } var loadResp map[string]any if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil { return fmt.Errorf("load code assist: %w", errLoad) } tierID := "legacy-tier" if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers { for _, rawTier := range tiers { tier, okTier := rawTier.(map[string]any) if !okTier { continue } if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault { if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" { tierID = strings.TrimSpace(id) break } } } } projectID := trimmedRequest if projectID == "" { if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject { projectID = strings.TrimSpace(id) } if projectID == "" { if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject { if id, okID := projectMap["id"].(string); okID { projectID = strings.TrimSpace(id) } } } } if projectID == "" { return &projectSelectionRequiredError{} } onboardReqBody := map[string]any{ "tierId": tierID, "metadata": metadata, "cloudaicompanionProject": projectID, } storage.ProjectID = projectID for { var onboardResp map[string]any if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil { return fmt.Errorf("onboard user: %w", errOnboard) } if done, okDone := onboardResp["done"].(bool); okDone && done { responseProjectID := "" if resp, okResp := onboardResp["response"].(map[string]any); okResp { switch projectValue := resp["cloudaicompanionProject"].(type) { case map[string]any: if id, okID := projectValue["id"].(string); okID { responseProjectID = strings.TrimSpace(id) } case string: responseProjectID = strings.TrimSpace(projectValue) } } finalProjectID := projectID if responseProjectID != "" { if explicitProject && !strings.EqualFold(responseProjectID, projectID) { // Check if this is a free user (gen-lang-client projects or free/legacy tier) isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") || strings.EqualFold(tierID, "FREE") || strings.EqualFold(tierID, "LEGACY") if isFreeUser { // For free users, use backend project ID for preview model access log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID) log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID) finalProjectID = responseProjectID } else { // Pro users: keep requested project ID (original behavior) log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID) } } else { finalProjectID = responseProjectID } } storage.ProjectID = strings.TrimSpace(finalProjectID) if storage.ProjectID == "" { storage.ProjectID = strings.TrimSpace(projectID) } if storage.ProjectID == "" { return fmt.Errorf("onboard user completed without project id") } log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID) return nil } log.Println("Onboarding in progress, waiting 5 seconds...") time.Sleep(5 * time.Second) } } func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error { endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint) if strings.HasPrefix(endpoint, "operations/") { endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint) } var reader io.Reader if body != nil { rawBody, errMarshal := json.Marshal(body) if errMarshal != nil { return fmt.Errorf("marshal request body: %w", errMarshal) } reader = bytes.NewReader(rawBody) } req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader) if errRequest != nil { return fmt.Errorf("create request: %w", errRequest) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", geminiCLIUserAgent) req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient) req.Header.Set("Client-Metadata", geminiCLIClientMetadata) resp, errDo := httpClient.Do(req) if errDo != nil { return fmt.Errorf("execute request: %w", errDo) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("response body close error: %v", errClose) } }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) } if result == nil { _, _ = io.Copy(io.Discard, resp.Body) return nil } if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil { return fmt.Errorf("decode response body: %w", errDecode) } return nil } func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) { req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody) if errRequest != nil { return nil, fmt.Errorf("could not create project list request: %w", errRequest) } resp, errDo := httpClient.Do(req) if errDo != nil { return nil, fmt.Errorf("failed to execute project list request: %w", errDo) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("response body close error: %v", errClose) } }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) } var projects interfaces.GCPProject if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil { return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode) } return projects.Projects, nil } func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) { serviceUsageURL := "https://serviceusage.googleapis.com" requiredServices := []string{ "cloudaicompanion.googleapis.com", } for _, service := range requiredServices { checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service) req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, http.NoBody) if errRequest != nil { return false, fmt.Errorf("failed to create request: %w", errRequest) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", geminiCLIUserAgent) resp, errDo := httpClient.Do(req) if errDo != nil { return false, fmt.Errorf("failed to execute request: %w", errDo) } if resp.StatusCode == http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" { _ = resp.Body.Close() continue } } _ = resp.Body.Close() enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service) req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}")) if errRequest != nil { return false, fmt.Errorf("failed to create request: %w", errRequest) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", geminiCLIUserAgent) resp, errDo = httpClient.Do(req) if errDo != nil { return false, fmt.Errorf("failed to execute request: %w", errDo) } bodyBytes, _ := io.ReadAll(resp.Body) errMessage := string(bodyBytes) errMessageResult := gjson.GetBytes(bodyBytes, "error.message") if errMessageResult.Exists() { errMessage = errMessageResult.String() } if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { _ = resp.Body.Close() continue } else if resp.StatusCode == http.StatusBadRequest { _ = resp.Body.Close() if strings.Contains(strings.ToLower(errMessage), "already enabled") { continue } } _ = resp.Body.Close() return false, fmt.Errorf("project activation required: %s", errMessage) } return true, nil }