diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4c4aafe7..04ec21a9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,7 +19,7 @@ jobs: - run: git fetch --force --tags - uses: actions/setup-go@v4 with: - go-version: '>=1.24.0' + go-version: '>=1.26.0' cache: true - name: Generate Build Metadata run: | diff --git a/Dockerfile b/Dockerfile index 98509423..cde6205a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app diff --git a/go.mod b/go.mod index 78451bc4..fea3bc10 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/router-for-me/CLIProxyAPI/v6 -go 1.24.0 +go 1.26.0 require ( github.com/andybalholm/brotli v1.0.6 diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index c9b4865f..49a6e780 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1193,6 +1193,30 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { } ts.ProjectID = strings.Join(projects, ",") ts.Checked = true + } else if strings.EqualFold(requestedProjectID, "GOOGLE_ONE") { + ts.Auto = false + if errSetup := performGeminiCLISetup(ctx, gemClient, &ts, ""); errSetup != nil { + log.Errorf("Google One auto-discovery failed: %v", errSetup) + SetOAuthSessionError(state, "Google One auto-discovery failed") + return + } + if strings.TrimSpace(ts.ProjectID) == "" { + log.Error("Google One auto-discovery returned empty project ID") + SetOAuthSessionError(state, "Google One auto-discovery returned empty project ID") + return + } + isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID) + if errCheck != nil { + log.Errorf("Failed to verify Cloud AI API status: %v", errCheck) + SetOAuthSessionError(state, "Failed to verify Cloud AI API status") + return + } + ts.Checked = isChecked + if !isChecked { + log.Error("Cloud AI API is not enabled for the auto-discovered project") + SetOAuthSessionError(state, "Cloud AI API not enabled") + return + } } else { if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil { log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure) @@ -2124,7 +2148,48 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage } } if projectID == "" { - return &projectSelectionRequiredError{} + // Auto-discovery: try onboardUser without specifying a project + // to let Google auto-provision one (matches Gemini CLI headless behavior + // and Antigravity's FetchProjectID pattern). + autoOnboardReq := map[string]any{ + "tierId": tierID, + "metadata": metadata, + } + + autoCtx, autoCancel := context.WithTimeout(ctx, 30*time.Second) + defer autoCancel() + for attempt := 1; ; attempt++ { + var onboardResp map[string]any + if errOnboard := callGeminiCLI(autoCtx, httpClient, "onboardUser", autoOnboardReq, &onboardResp); errOnboard != nil { + return fmt.Errorf("auto-discovery onboardUser: %w", errOnboard) + } + + if done, okDone := onboardResp["done"].(bool); okDone && done { + if resp, okResp := onboardResp["response"].(map[string]any); okResp { + switch v := resp["cloudaicompanionProject"].(type) { + case string: + projectID = strings.TrimSpace(v) + case map[string]any: + if id, okID := v["id"].(string); okID { + projectID = strings.TrimSpace(id) + } + } + } + break + } + + log.Debugf("Auto-discovery: onboarding in progress, attempt %d...", attempt) + select { + case <-autoCtx.Done(): + return &projectSelectionRequiredError{} + case <-time.After(2 * time.Second): + } + } + + if projectID == "" { + return &projectSelectionRequiredError{} + } + log.Infof("Auto-discovered project ID via onboarding: %s", projectID) } onboardReqBody := map[string]any{ diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index 0137f234..72f73d32 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -28,8 +28,7 @@ func (h *Handler) GetConfig(c *gin.Context) { c.JSON(200, gin.H{}) return } - cfgCopy := *h.cfg - c.JSON(200, &cfgCopy) + c.JSON(200, new(*h.cfg)) } type releaseInfo struct { diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go index b5626ce9..a12733e2 100644 --- a/internal/api/modules/amp/amp.go +++ b/internal/api/modules/amp/amp.go @@ -127,8 +127,7 @@ func (m *AmpModule) Register(ctx modules.Context) error { m.modelMapper = NewModelMapper(settings.ModelMappings) // Store initial config for partial reload comparison - settingsCopy := settings - m.lastConfig = &settingsCopy + m.lastConfig = new(settings) // Initialize localhost restriction setting (hot-reloadable) m.setRestrictToLocalhost(settings.RestrictManagementToLocalhost) diff --git a/internal/cmd/anthropic_login.go b/internal/cmd/anthropic_login.go index dafdd02b..f7381461 100644 --- a/internal/cmd/anthropic_login.go +++ b/internal/cmd/anthropic_login.go @@ -40,8 +40,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) { _, savedPath, err := manager.Login(context.Background(), "claude", cfg, authOpts) if err != nil { - var authErr *claude.AuthenticationError - if errors.As(err, &authErr) { + if authErr, ok := errors.AsType[*claude.AuthenticationError](err); ok { log.Error(claude.GetUserFriendlyMessage(authErr)) if authErr.Type == claude.ErrPortInUse.Type { os.Exit(claude.ErrPortInUse.Code) diff --git a/internal/cmd/iflow_login.go b/internal/cmd/iflow_login.go index 07360b8c..49e18e5b 100644 --- a/internal/cmd/iflow_login.go +++ b/internal/cmd/iflow_login.go @@ -32,8 +32,7 @@ func DoIFlowLogin(cfg *config.Config, options *LoginOptions) { _, savedPath, err := manager.Login(context.Background(), "iflow", cfg, authOpts) if err != nil { - var emailErr *sdkAuth.EmailRequiredError - if errors.As(err, &emailErr) { + if emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok { log.Error(emailErr.Error()) return } diff --git a/internal/cmd/login.go b/internal/cmd/login.go index b5129cfd..1d8a1ae3 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -100,49 +100,74 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) { log.Info("Authentication successful.") - projects, errProjects := fetchGCPProjects(ctx, httpClient) - if errProjects != nil { - log.Errorf("Failed to get project list: %v", errProjects) - return + var activatedProjects []string + + useGoogleOne := false + if trimmedProjectID == "" && promptFn != nil { + fmt.Println("\nSelect login mode:") + fmt.Println(" 1. Code Assist (GCP project, manual selection)") + fmt.Println(" 2. Google One (personal account, auto-discover project)") + choice, errPrompt := promptFn("Enter choice [1/2] (default: 1): ") + if errPrompt == nil && strings.TrimSpace(choice) == "2" { + useGoogleOne = true + } } - selectedProjectID := promptForProjectSelection(projects, trimmedProjectID, promptFn) - projectSelections, errSelection := resolveProjectSelections(selectedProjectID, projects) - if errSelection != nil { - log.Errorf("Invalid project selection: %v", errSelection) - return - } - if len(projectSelections) == 0 { - log.Error("No project selected; aborting login.") - return - } - - activatedProjects := make([]string, 0, len(projectSelections)) - seenProjects := make(map[string]bool) - for _, candidateID := range projectSelections { - log.Infof("Activating project %s", candidateID) - if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil { - var projectErr *projectSelectionRequiredError - if errors.As(errSetup, &projectErr) { - log.Error("Failed to start user onboarding: A project ID is required.") - showProjectSelectionHelp(storage.Email, projects) - return - } - log.Errorf("Failed to complete user setup: %v", errSetup) + if useGoogleOne { + log.Info("Google One mode: auto-discovering project...") + if errSetup := performGeminiCLISetup(ctx, httpClient, storage, ""); errSetup != nil { + log.Errorf("Google One auto-discovery failed: %v", errSetup) return } - finalID := strings.TrimSpace(storage.ProjectID) - if finalID == "" { - finalID = candidateID + autoProject := strings.TrimSpace(storage.ProjectID) + if autoProject == "" { + log.Error("Google One auto-discovery returned empty project ID") + return + } + log.Infof("Auto-discovered project: %s", autoProject) + activatedProjects = []string{autoProject} + } else { + projects, errProjects := fetchGCPProjects(ctx, httpClient) + if errProjects != nil { + log.Errorf("Failed to get project list: %v", errProjects) + return } - // Skip duplicates - if seenProjects[finalID] { - log.Infof("Project %s already activated, skipping", finalID) - continue + selectedProjectID := promptForProjectSelection(projects, trimmedProjectID, promptFn) + projectSelections, errSelection := resolveProjectSelections(selectedProjectID, projects) + if errSelection != nil { + log.Errorf("Invalid project selection: %v", errSelection) + return + } + if len(projectSelections) == 0 { + log.Error("No project selected; aborting login.") + return + } + + seenProjects := make(map[string]bool) + for _, candidateID := range projectSelections { + log.Infof("Activating project %s", candidateID) + if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil { + if _, ok := errors.AsType[*projectSelectionRequiredError](errSetup); ok { + log.Error("Failed to start user onboarding: A project ID is required.") + showProjectSelectionHelp(storage.Email, projects) + return + } + log.Errorf("Failed to complete user setup: %v", errSetup) + return + } + finalID := strings.TrimSpace(storage.ProjectID) + if finalID == "" { + finalID = candidateID + } + + if seenProjects[finalID] { + log.Infof("Project %s already activated, skipping", finalID) + continue + } + seenProjects[finalID] = true + activatedProjects = append(activatedProjects, finalID) } - seenProjects[finalID] = true - activatedProjects = append(activatedProjects, finalID) } storage.Auto = false @@ -235,7 +260,48 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage } } if projectID == "" { - return &projectSelectionRequiredError{} + // Auto-discovery: try onboardUser without specifying a project + // to let Google auto-provision one (matches Gemini CLI headless behavior + // and Antigravity's FetchProjectID pattern). + autoOnboardReq := map[string]any{ + "tierId": tierID, + "metadata": metadata, + } + + autoCtx, autoCancel := context.WithTimeout(ctx, 30*time.Second) + defer autoCancel() + for attempt := 1; ; attempt++ { + var onboardResp map[string]any + if errOnboard := callGeminiCLI(autoCtx, httpClient, "onboardUser", autoOnboardReq, &onboardResp); errOnboard != nil { + return fmt.Errorf("auto-discovery onboardUser: %w", errOnboard) + } + + if done, okDone := onboardResp["done"].(bool); okDone && done { + if resp, okResp := onboardResp["response"].(map[string]any); okResp { + switch v := resp["cloudaicompanionProject"].(type) { + case string: + projectID = strings.TrimSpace(v) + case map[string]any: + if id, okID := v["id"].(string); okID { + projectID = strings.TrimSpace(id) + } + } + } + break + } + + log.Debugf("Auto-discovery: onboarding in progress, attempt %d...", attempt) + select { + case <-autoCtx.Done(): + return &projectSelectionRequiredError{} + case <-time.After(2 * time.Second): + } + } + + if projectID == "" { + return &projectSelectionRequiredError{} + } + log.Infof("Auto-discovered project ID via onboarding: %s", projectID) } onboardReqBody := map[string]any{ @@ -617,7 +683,7 @@ func updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStor return } - finalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, false) + finalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, true) if record.Metadata == nil { record.Metadata = make(map[string]any) diff --git a/internal/cmd/openai_login.go b/internal/cmd/openai_login.go index 5f2fb162..783a9484 100644 --- a/internal/cmd/openai_login.go +++ b/internal/cmd/openai_login.go @@ -54,8 +54,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { _, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts) if err != nil { - var authErr *codex.AuthenticationError - if errors.As(err, &authErr) { + if authErr, ok := errors.AsType[*codex.AuthenticationError](err); ok { log.Error(codex.GetUserFriendlyMessage(authErr)) if authErr.Type == codex.ErrPortInUse.Type { os.Exit(codex.ErrPortInUse.Code) diff --git a/internal/cmd/qwen_login.go b/internal/cmd/qwen_login.go index 92a57aa5..10179fa8 100644 --- a/internal/cmd/qwen_login.go +++ b/internal/cmd/qwen_login.go @@ -44,8 +44,7 @@ func DoQwenLogin(cfg *config.Config, options *LoginOptions) { _, savedPath, err := manager.Login(context.Background(), "qwen", cfg, authOpts) if err != nil { - var emailErr *sdkAuth.EmailRequiredError - if errors.As(err, &emailErr) { + if emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok { log.Error(emailErr.Error()) return } diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 3145023e..3fa2a3b5 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -601,8 +601,7 @@ func (r *ModelRegistry) SetModelQuotaExceeded(clientID, modelID string) { defer r.mutex.Unlock() if registration, exists := r.models[modelID]; exists { - now := time.Now() - registration.QuotaExceededClients[clientID] = &now + registration.QuotaExceededClients[clientID] = new(time.Now()) log.Debugf("Marked model %s as quota exceeded for client %s", modelID, clientID) } } diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index 4ac7bdba..3e218c0f 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -899,8 +899,7 @@ func parseRetryDelay(errorBody []byte) (*time.Duration, error) { if matches := re.FindStringSubmatch(message); len(matches) > 1 { seconds, err := strconv.Atoi(matches[1]) if err == nil { - duration := time.Duration(seconds) * time.Second - return &duration, nil + return new(time.Duration(seconds) * time.Second), nil } } } diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index 6d86c247..cdea33ee 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -90,6 +90,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() { template, _ = sjson.Set(template, "usage.prompt_tokens", inputTokensResult.Int()) } + if cachedTokensResult := usageResult.Get("input_tokens_details.cached_tokens"); cachedTokensResult.Exists() { + template, _ = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int()) + } if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() { template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int()) } @@ -205,6 +208,9 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() { template, _ = sjson.Set(template, "usage.prompt_tokens", inputTokensResult.Int()) } + if cachedTokensResult := usageResult.Get("input_tokens_details.cached_tokens"); cachedTokensResult.Exists() { + template, _ = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int()) + } if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() { template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int()) } diff --git a/sdk/api/handlers/gemini/gemini-cli_handlers.go b/sdk/api/handlers/gemini/gemini-cli_handlers.go index 917902e7..07cedc55 100644 --- a/sdk/api/handlers/gemini/gemini-cli_handlers.go +++ b/sdk/api/handlers/gemini/gemini-cli_handlers.go @@ -185,8 +185,7 @@ func (h *GeminiCLIAPIHandler) handleInternalGenerateContent(c *gin.Context, rawJ func (h *GeminiCLIAPIHandler) forwardCLIStream(c *gin.Context, flusher http.Flusher, alt string, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) { var keepAliveInterval *time.Duration if alt != "" { - disabled := time.Duration(0) - keepAliveInterval = &disabled + keepAliveInterval = new(time.Duration(0)) } h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{ diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go index 71c485ad..a5eb337d 100644 --- a/sdk/api/handlers/gemini/gemini_handlers.go +++ b/sdk/api/handlers/gemini/gemini_handlers.go @@ -300,8 +300,7 @@ func (h *GeminiAPIHandler) handleGenerateContent(c *gin.Context, modelName strin func (h *GeminiAPIHandler) forwardGeminiStream(c *gin.Context, flusher http.Flusher, alt string, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) { var keepAliveInterval *time.Duration if alt != "" { - disabled := time.Duration(0) - keepAliveInterval = &disabled + keepAliveInterval = new(time.Duration(0)) } h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{ diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go index ecca0a00..6ed31d6d 100644 --- a/sdk/auth/antigravity.go +++ b/sdk/auth/antigravity.go @@ -28,8 +28,7 @@ func (AntigravityAuthenticator) Provider() string { return "antigravity" } // RefreshLead instructs the manager to refresh five minutes before expiry. func (AntigravityAuthenticator) RefreshLead() *time.Duration { - lead := 5 * time.Minute - return &lead + return new(5 * time.Minute) } // Login launches a local OAuth flow to obtain antigravity tokens and persists them. diff --git a/sdk/auth/claude.go b/sdk/auth/claude.go index a6b19af5..706763b3 100644 --- a/sdk/auth/claude.go +++ b/sdk/auth/claude.go @@ -32,8 +32,7 @@ func (a *ClaudeAuthenticator) Provider() string { } func (a *ClaudeAuthenticator) RefreshLead() *time.Duration { - d := 4 * time.Hour - return &d + return new(4 * time.Hour) } func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go index b655a239..c81842eb 100644 --- a/sdk/auth/codex.go +++ b/sdk/auth/codex.go @@ -34,8 +34,7 @@ func (a *CodexAuthenticator) Provider() string { } func (a *CodexAuthenticator) RefreshLead() *time.Duration { - d := 5 * 24 * time.Hour - return &d + return new(5 * 24 * time.Hour) } func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index b1147e9f..4715d7f7 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "io" "io/fs" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -186,15 +188,21 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if provider == "" { provider = "unknown" } - if provider == "antigravity" { + if provider == "antigravity" || provider == "gemini" { projectID := "" if pid, ok := metadata["project_id"].(string); ok { projectID = strings.TrimSpace(pid) } if projectID == "" { - accessToken := "" - if token, ok := metadata["access_token"].(string); ok { - accessToken = strings.TrimSpace(token) + accessToken := extractAccessToken(metadata) + // For gemini type, the stored access_token is likely expired (~1h lifetime). + // Refresh it using the long-lived refresh_token before querying. + if provider == "gemini" { + if tokenMap, ok := metadata["token"].(map[string]any); ok { + if refreshed, errRefresh := refreshGeminiAccessToken(tokenMap, http.DefaultClient); errRefresh == nil { + accessToken = refreshed + } + } } if accessToken != "" { fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient) @@ -313,6 +321,67 @@ func (s *FileTokenStore) baseDirSnapshot() string { return s.baseDir } +func extractAccessToken(metadata map[string]any) string { + if at, ok := metadata["access_token"].(string); ok { + if v := strings.TrimSpace(at); v != "" { + return v + } + } + if tokenMap, ok := metadata["token"].(map[string]any); ok { + if at, ok := tokenMap["access_token"].(string); ok { + if v := strings.TrimSpace(at); v != "" { + return v + } + } + } + return "" +} + +func refreshGeminiAccessToken(tokenMap map[string]any, httpClient *http.Client) (string, error) { + refreshToken, _ := tokenMap["refresh_token"].(string) + clientID, _ := tokenMap["client_id"].(string) + clientSecret, _ := tokenMap["client_secret"].(string) + tokenURI, _ := tokenMap["token_uri"].(string) + + if refreshToken == "" || clientID == "" || clientSecret == "" { + return "", fmt.Errorf("missing refresh credentials") + } + if tokenURI == "" { + tokenURI = "https://oauth2.googleapis.com/token" + } + + data := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + } + + resp, err := httpClient.PostForm(tokenURI, data) + if err != nil { + return "", fmt.Errorf("refresh request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("refresh failed: status %d", resp.StatusCode) + } + + var result map[string]any + if errUnmarshal := json.Unmarshal(body, &result); errUnmarshal != nil { + return "", fmt.Errorf("decode refresh response: %w", errUnmarshal) + } + + newAccessToken, _ := result["access_token"].(string) + if newAccessToken == "" { + return "", fmt.Errorf("no access_token in refresh response") + } + + tokenMap["access_token"] = newAccessToken + return newAccessToken, nil +} + // jsonEqual compares two JSON blobs by parsing them into Go objects and deep comparing. func jsonEqual(a, b []byte) bool { var objA any diff --git a/sdk/auth/filestore_test.go b/sdk/auth/filestore_test.go new file mode 100644 index 00000000..9e135ad4 --- /dev/null +++ b/sdk/auth/filestore_test.go @@ -0,0 +1,80 @@ +package auth + +import "testing" + +func TestExtractAccessToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata map[string]any + expected string + }{ + { + "antigravity top-level access_token", + map[string]any{"access_token": "tok-abc"}, + "tok-abc", + }, + { + "gemini nested token.access_token", + map[string]any{ + "token": map[string]any{"access_token": "tok-nested"}, + }, + "tok-nested", + }, + { + "top-level takes precedence over nested", + map[string]any{ + "access_token": "tok-top", + "token": map[string]any{"access_token": "tok-nested"}, + }, + "tok-top", + }, + { + "empty metadata", + map[string]any{}, + "", + }, + { + "whitespace-only access_token", + map[string]any{"access_token": " "}, + "", + }, + { + "wrong type access_token", + map[string]any{"access_token": 12345}, + "", + }, + { + "token is not a map", + map[string]any{"token": "not-a-map"}, + "", + }, + { + "nested whitespace-only", + map[string]any{ + "token": map[string]any{"access_token": " "}, + }, + "", + }, + { + "fallback to nested when top-level empty", + map[string]any{ + "access_token": "", + "token": map[string]any{"access_token": "tok-fallback"}, + }, + "tok-fallback", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractAccessToken(tt.metadata) + if got != tt.expected { + t.Errorf("extractAccessToken() = %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/sdk/auth/iflow.go b/sdk/auth/iflow.go index 6d4ff946..a695311d 100644 --- a/sdk/auth/iflow.go +++ b/sdk/auth/iflow.go @@ -26,8 +26,7 @@ func (a *IFlowAuthenticator) Provider() string { return "iflow" } // RefreshLead indicates how soon before expiry a refresh should be attempted. func (a *IFlowAuthenticator) RefreshLead() *time.Duration { - d := 24 * time.Hour - return &d + return new(24 * time.Hour) } // Login performs the OAuth code flow using a local callback server. diff --git a/sdk/auth/qwen.go b/sdk/auth/qwen.go index 151fba68..310d4987 100644 --- a/sdk/auth/qwen.go +++ b/sdk/auth/qwen.go @@ -27,8 +27,7 @@ func (a *QwenAuthenticator) Provider() string { } func (a *QwenAuthenticator) RefreshLead() *time.Duration { - d := 3 * time.Hour - return &d + return new(3 * time.Hour) } func (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 38dadc7c..6b50e291 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -599,8 +599,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req return cliproxyexecutor.Response{}, errCtx } result.Error = &Error{Message: errExec.Error()} - var se cliproxyexecutor.StatusError - if errors.As(errExec, &se) && se != nil { + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { result.Error.HTTPStatus = se.StatusCode() } if ra := retryAfterFromError(errExec); ra != nil { @@ -655,8 +654,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, return cliproxyexecutor.Response{}, errCtx } result.Error = &Error{Message: errExec.Error()} - var se cliproxyexecutor.StatusError - if errors.As(errExec, &se) && se != nil { + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil { result.Error.HTTPStatus = se.StatusCode() } if ra := retryAfterFromError(errExec); ra != nil { @@ -710,8 +708,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string return nil, errCtx } rerr := &Error{Message: errStream.Error()} - var se cliproxyexecutor.StatusError - if errors.As(errStream, &se) && se != nil { + if se, ok := errors.AsType[cliproxyexecutor.StatusError](errStream); ok && se != nil { rerr.HTTPStatus = se.StatusCode() } result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr} @@ -732,8 +729,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string if chunk.Err != nil && !failed { failed = true rerr := &Error{Message: chunk.Err.Error()} - var se cliproxyexecutor.StatusError - if errors.As(chunk.Err, &se) && se != nil { + if se, ok := errors.AsType[cliproxyexecutor.StatusError](chunk.Err); ok && se != nil { rerr.HTTPStatus = se.StatusCode() } m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: false, Error: rerr}) @@ -1431,8 +1427,7 @@ func retryAfterFromError(err error) *time.Duration { if retryAfter == nil { return nil } - val := *retryAfter - return &val + return new(*retryAfter) } func statusCodeFromResult(err *Error) int {