| 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") |
| } |
|
|
| |
| 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) { |
| |
| isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") || |
| strings.EqualFold(tierID, "FREE") || |
| strings.EqualFold(tierID, "LEGACY") |
|
|
| if isFreeUser { |
| |
| 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 { |
| |
| 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 |
| } |
|
|